51 KiB
Phase 2: Vault Core and Rendering - Research
Researched: 2026-02-28 Domain: Markdown parsing (pulldown-cmark), ratatui widget system, syntax highlighting (syntect), scrolling, CGA color palette, file I/O Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
Color palette & text styling
- Classic CGA 16-color palette for accent elements (headers, code, links, emphasis markers)
- Body text uses terminal default foreground/background — respects user's TERM color scheme
- CGA accent colors only override for specific elements, not body text
- Heading levels (H1-H6) distinguished by both color AND decorators (e.g. H1 = bright cyan + ══ underline, H2 = bright yellow, H3 = green, etc.)
- Bold uses terminal bold attribute, italic uses terminal italic attribute, inline code uses terminal styling — modern terminals support these natively
- No need to fall back to color-only for bold/italic since SSH users have modern terminal emulators
Screen layout & chrome
- No content borders — content goes edge-to-edge, maximizing reading space
- Single status bar at the bottom only (vim-style)
- Status bar content: current filename on the left, keyboard hints (q:Quit j/k:Scroll) on the right
- Status bar style: reverse video (inverted colors) — classic terminal status bar look
- No top bar, no title bar, no breadcrumb bar (breadcrumb comes in Phase 3)
Code blocks & tables
- Fenced code blocks get syntax highlighting using CGA palette colors (cyan keywords, green strings, etc.)
- Code blocks framed with indent + colored background + rounded-corner box-drawing borders (╭─╮│╰─╯)
- GFM tables rendered with full box-drawing grid (┌┬┐├┼┤└┴┘) — classic DOS table look
- Table header row styled with bold + CGA accent color (e.g. bright cyan or yellow)
Scrolling
- j/k and arrow keys scroll one line at a time
- PgUp/PgDn scroll a full page height
- No splash screen on startup — go straight to content
Claude's Discretion
- Exact CGA color assignments per heading level (H1-H6)
- Exact decorator style per heading level (═══ vs ─── vs none)
- Syntax highlighting color mapping (which CGA color for keywords, strings, comments, etc.)
- Blockquote visual style
- Horizontal rule style
- Image placeholder styling
- List bullet/number styling
- Scroll boundary behavior (stop or bounce)
Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| REND-01 | User sees H1-H6 headers with visual hierarchy and styling | pulldown-cmark Tag::Heading { level: HeadingLevel, .. } event; ratatui Line/Span with Color::LightCyan + Modifier::BOLD + decorator spans |
| REND-02 | User sees bold, italic, and inline code rendered with terminal styling | pulldown-cmark Tag::Strong, Tag::Emphasis, Event::Code; ratatui Modifier::BOLD, Modifier::ITALIC, Color::LightCyan for inline code |
| REND-03 | User sees fenced code blocks with syntax highlighting | pulldown-cmark CodeBlockKind::Fenced with language name; syntect 5.3 HighlightLines; CGA color mapping function |
| REND-04 | User sees ordered and unordered lists with proper indentation | pulldown-cmark Tag::List(Option), Tag::Item; u64=None is unordered, u64=Some(n) is ordered starting at n |
| REND-05 | User sees blockquotes with visual distinction | pulldown-cmark Tag::BlockQuote; render with left-margin │ character in CGA color + indented content |
| REND-06 | User sees horizontal rules as visual separators | pulldown-cmark Event::Rule; render as a full-width line of ─── characters in dim/DarkGray |
| REND-07 | User sees [IMAGE: alt text] placeholders for images | pulldown-cmark Tag::Image { .. }; collect alt text from nested Text events; render as Span "[IMAGE: {alt}]" |
| REND-08 | User sees GFM tables rendered with aligned columns | pulldown-cmark Tag::Table(Vec) with Options::ENABLE_TABLES; two-pass rendering: collect cells then emit box-drawing grid |
| REND-09 | User sees box-drawing borders on content panels | Code blocks use BorderType::Rounded (╭╮╰╯); tables use manual box-drawing chars; no borders on main content area per decision |
| REND-10 | User sees CGA-era retro color theme (cyan/magenta/green on dark) | ratatui Color::LightCyan, Color::Cyan, Color::LightGreen, Color::Green, Color::LightMagenta, Color::Yellow, Color::White |
| NAV-05 | User can scroll content with j/k, arrows, PgUp/PgDn | App state scroll_offset: u16; Paragraph::scroll((y, 0)); compute max_scroll from line_count(width) - viewport_height |
| NAV-06 | User lands on index.md as the entry point | On init: vault_path.join("index.md"); if not exists show BBS error screen; use std::fs::read_to_string |
| NAV-07 | User sees graceful error page when a linked file is not found | BBS-styled error widget with box-drawing border; message + vault path + create hint |
| NAV-08 | User sees keyboard hints in status bar | Reverse-video single-line Paragraph at bottom; Layout split [Min(0), Length(1)] |
| NAV-09 | App handles terminal resize without breaking layout | Event::Resize in crossterm event loop; ratatui autoresize in draw() handles buffer resize automatically for Viewport::Fullscreen |
| </phase_requirements> |
Summary
Phase 2 is primarily a markdown parsing and rendering pipeline problem. The critical path runs: read file from disk → parse with pulldown-cmark → convert events to Vec<Line> (ratatui's styled text lines) → store in App state → render with Paragraph::scroll() → handle scroll keys. Every requirement maps cleanly to a specific pulldown-cmark event type and a ratatui rendering primitive.
The ratatui 0.30 Widget trait has a verified, stable signature: fn render(self, area: Rect, buf: &mut Buffer) where Self: Sized. Custom widgets implement either consuming (impl Widget for MyWidget) or reference-based (impl Widget for &MyWidget) patterns. For Phase 2, the recommended approach is to build a DocumentWidget struct holding Vec<ratatui::text::Line> and implementing Widget on a reference to it, since the document content is stable between renders and only scroll_offset changes.
Syntax highlighting uses syntect 5.3.0 + syntect-tui 3.0. The HighlightLines API returns Vec<(syntect::highlighting::Style, &str)> per line; syntect-tui::into_span() converts each tuple to a ratatui::text::Span. Since the user specified CGA 16-color output, a custom RGB-to-CGA mapping function is needed: syntect themes produce RGB values and these must be quantized to the nearest of the 16 ratatui named colors. The ~200KB binary overhead from embedded syntax definitions is acceptable.
GFM table rendering is the most complex single requirement: pulldown-cmark delivers table events in streaming order (TableHead → TableCell* → TableRow* → TableCell*), requiring a two-pass approach — first collect all cell content with column widths, then emit the full box-drawing grid as Line objects. This is purely Rust data transformation, no additional library needed.
Primary recommendation: Build a MarkdownRenderer struct that converts a markdown string to Vec<ratatui::text::Line<'static>> (owned strings, no lifetime leakage). Store the rendered lines in App state. Use Paragraph::new(lines).scroll((offset, 0)) for display. Add syntect and syntect-tui to Cargo.toml.
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| pulldown-cmark | 0.13.1 | Markdown event-based parser | Already decided pre-Phase 1; CommonMark + GFM extensions; iterator-based, no allocation tree |
| ratatui | 0.30.0 | TUI rendering, Widget/Paragraph/Line/Span | Already a dependency; 0.30 stable Widget trait |
| syntect | 5.3.0 | Syntax highlighting for code blocks | De-facto Rust standard; embedded syntax definitions; HighlightLines API |
| syntect-tui | 3.0 | Convert syntect Style to ratatui Span | Lightweight bridge; avoids manual color conversion for most cases |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| std::fs | stdlib | Read markdown files from vault_path | Opening index.md and linked files |
| std::path::PathBuf | stdlib | Resolve file paths within vault | Join vault_path with relative file paths |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| syntect | Manual keyword regex highlighter | Manual approach avoids ~200KB binary overhead but requires per-language keyword lists; syntect has 80+ languages for free |
| syntect | tree-sitter | tree-sitter is more accurate but requires C compilation and is heavier; overkill for a terminal reader |
| pulldown-cmark | comrak | Pre-Phase 1 decision locked pulldown-cmark; comrak builds a DOM tree which uses more memory |
| tui-markdown crate | Custom renderer | tui-markdown is experimental PoC (0.3.7); cannot produce BBS-specific CGA aesthetics without heavy forking; building from pulldown-cmark events gives full control |
Installation:
[dependencies]
ratatui = "0.30.0" # already present
pulldown-cmark = "0.13.1"
syntect = { version = "5.3.0", default-features = false, features = ["default-fancy"] }
syntect-tui = "3.0"
Note: default-features = false, features = ["default-fancy"] uses the pure-Rust fancy-regex engine instead of the Oniguruma C library. This avoids build complexity on non-standard systems (including some SSH server environments). The default-fancy feature still includes all embedded syntax definitions.
Architecture Patterns
Recommended Project Structure
src/
├── main.rs # unchanged from Phase 1
├── config.rs # unchanged from Phase 1
├── signals.rs # unchanged from Phase 1
├── terminal.rs # unchanged from Phase 1
├── app.rs # extended: add document state, scroll, key handling
├── vault.rs # NEW: file loading, index.md resolution, error states
├── renderer.rs # NEW: pulldown-cmark → Vec<Line> conversion pipeline
└── highlighter.rs # NEW: syntect integration, CGA color mapping
Pattern 1: Markdown-to-Lines Conversion Pipeline
What: Convert a markdown string to Vec<ratatui::text::Line<'static>> using pulldown-cmark events. This is the core of Phase 2.
When to use: Called once when a document is loaded. The result is stored in App state and reused on every frame render.
Key insight: Use 'static lifetime for the output (owned String content in each Span) so there are no lifetime dependencies on the input markdown string. This simplifies App state dramatically.
// Source: https://docs.rs/pulldown-cmark/latest/pulldown_cmark/
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, HeadingLevel, CodeBlockKind, Alignment};
use ratatui::text::{Line, Span};
use ratatui::style::{Color, Modifier, Style};
pub struct MarkdownRenderer {
pub lines: Vec<Line<'static>>,
// Internal state during parsing
current_spans: Vec<Span<'static>>,
current_style: Style,
in_code_block: bool,
code_lang: String,
code_buf: String,
list_depth: u32,
list_counters: Vec<Option<u64>>, // None=unordered, Some(n)=ordered at n
in_table: bool,
table_columns: Vec<Alignment>,
table_rows: Vec<Vec<String>>, // raw cell text (two-pass for tables)
in_table_head: bool,
}
Event loop skeleton:
// Source: https://docs.rs/pulldown-cmark/latest/pulldown_cmark/
pub fn render_markdown(input: &str) -> Vec<Line<'static>> {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_GFM); // blockquote alerts
// TextMergeStream coalesces consecutive Text events
let parser = pulldown_cmark::TextMergeStream::new(Parser::new_ext(input, opts));
let mut state = RenderState::new();
for event in parser {
state.handle(event);
}
state.finish()
}
Pattern 2: pulldown-cmark Event Handling — Complete Event Map
All events relevant to Phase 2 requirements:
// Source: https://docs.rs/pulldown-cmark/latest/pulldown_cmark/enum.Event.html
// Source: https://docs.rs/pulldown-cmark/latest/pulldown_cmark/enum.Tag.html
match event {
// ── HEADING ──────────────────────────────────────────────────────────────
Event::Start(Tag::Heading { level, .. }) => {
// level: HeadingLevel (H1..H6)
// Flush current line, push heading style onto style stack
// After End(TagEnd::Heading), add underline decorator line
}
Event::End(TagEnd::Heading(_)) => {
// Emit the heading Line, then emit a decorator Line (═══ or ───)
}
// ── PARAGRAPH ────────────────────────────────────────────────────────────
Event::Start(Tag::Paragraph) => { /* nothing — spans accumulate */ }
Event::End(TagEnd::Paragraph) => {
// Flush current_spans → push Line to lines; push empty Line for spacing
}
// ── INLINE FORMATTING ────────────────────────────────────────────────────
Event::Start(Tag::Strong) => { push_modifier(Modifier::BOLD); }
Event::End(TagEnd::Strong) => { pop_modifier(Modifier::BOLD); }
Event::Start(Tag::Emphasis) => { push_modifier(Modifier::ITALIC); }
Event::End(TagEnd::Emphasis) => { pop_modifier(Modifier::ITALIC); }
// ── INLINE CODE ──────────────────────────────────────────────────────────
Event::Code(text) => {
// Render as Span with distinct style (e.g., Color::LightCyan + DIM background)
let span = Span::styled(text.to_string(), inline_code_style());
current_spans.push(span);
}
// ── TEXT ─────────────────────────────────────────────────────────────────
Event::Text(text) => {
// If in_code_block: accumulate to code_buf
// Otherwise: push Span with current_style
if in_code_block {
code_buf.push_str(&text);
} else {
current_spans.push(Span::styled(text.to_string(), current_style));
}
}
// ── CODE BLOCK ───────────────────────────────────────────────────────────
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
code_lang = lang.to_string();
in_code_block = true;
code_buf.clear();
}
Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
code_lang = String::new(); // no language, no highlighting
in_code_block = true;
code_buf.clear();
}
Event::End(TagEnd::CodeBlock) => {
// Emit code block: rounded borders + highlighted lines
emit_code_block(&code_buf, &code_lang, &mut lines);
in_code_block = false;
}
// ── LISTS ────────────────────────────────────────────────────────────────
Event::Start(Tag::List(start_num)) => {
list_counters.push(start_num); // None = bullet, Some(n) = ordered from n
}
Event::End(TagEnd::List(_)) => {
list_counters.pop();
// Push blank line after list
}
Event::Start(Tag::Item) => {
// Render bullet/number based on list_counters.last()
let prefix = match list_counters.last() {
Some(None) => " • ".to_string(),
Some(Some(n)) => format!(" {}. ", n),
None => " • ".to_string(),
};
// Increment counter for ordered lists
}
// ── BLOCKQUOTE ───────────────────────────────────────────────────────────
Event::Start(Tag::BlockQuote(_kind)) => {
// Push quote context: prepend "│ " to subsequent lines in CGA color
}
Event::End(TagEnd::BlockQuote(_)) => { /* pop quote context */ }
// ── HORIZONTAL RULE ──────────────────────────────────────────────────────
Event::Rule => {
let rule = Line::from(Span::styled(
"─".repeat(width), // width available at render time, not parse time
Style::default().fg(Color::DarkGray),
));
lines.push(rule);
lines.push(Line::default());
}
// ── IMAGE ─────────────────────────────────────────────────────────────────
Event::Start(Tag::Image { .. }) => { /* begin collecting alt text */ }
Event::End(TagEnd::Image) => {
let placeholder = format!("[IMAGE: {}]", collected_alt_text);
current_spans.push(Span::styled(placeholder, image_style()));
}
// ── TABLE (two-pass) ─────────────────────────────────────────────────────
Event::Start(Tag::Table(alignments)) => {
table_columns = alignments;
table_rows.clear();
in_table = true;
}
Event::End(TagEnd::Table) => {
emit_table(&table_columns, &table_rows, &mut lines);
in_table = false;
}
Event::Start(Tag::TableHead) => { in_table_head = true; }
Event::End(TagEnd::TableHead) => { in_table_head = false; }
Event::Start(Tag::TableRow) => { table_rows.push(Vec::new()); }
Event::Start(Tag::TableCell) => { /* begin cell accumulation */ }
Event::End(TagEnd::TableCell) => {
// Push accumulated cell text to current row
table_rows.last_mut().unwrap().push(current_cell_text.clone());
current_cell_text.clear();
}
// ── BREAKS ───────────────────────────────────────────────────────────────
Event::SoftBreak => { current_spans.push(Span::raw(" ")); }
Event::HardBreak => {
flush_line(&mut lines, &mut current_spans);
}
_ => {} // FootnoteReference, Html, InlineHtml, TaskListMarker handled as needed
}
IMPORTANT: Tag::List contains Option<u64> not Option<u32>. Ordered lists start at the u64 value. None means unordered. This is the actual type as of pulldown-cmark 0.13.1.
Pattern 3: ratatui Widget Trait — Verified 0.30 Signature
The Widget trait signature in ratatui 0.30.0 is:
// Source: https://docs.rs/ratatui/0.30.0/ratatui/widgets/trait.Widget.html
pub trait Widget {
fn render(self, area: Rect, buf: &mut Buffer)
where Self: Sized;
}
Breaking change from 0.28/0.29: WidgetRef no longer has a blanket impl of Widget. The correct pattern for a reusable widget stored in state is to implement Widget for a reference:
// Source: https://ratatui.rs/recipes/widgets/custom/
pub struct DocumentWidget {
lines: Vec<Line<'static>>,
scroll_offset: u16,
}
impl Widget for &DocumentWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
let paragraph = Paragraph::new(self.lines.clone())
.scroll((self.scroll_offset, 0));
paragraph.render(area, buf);
}
}
For widgets that need to modify their own state during render (e.g., update scroll limits after layout is known), implement Widget for &mut MyWidget.
Pattern 4: Scrolling with Paragraph
// Source: https://docs.rs/ratatui/0.30.0/ratatui/widgets/struct.Paragraph.html
// Scroll signature:
pub const fn scroll(self, offset: (u16, u16)) -> Paragraph<'a>
// (y, x) — y is lines, x is chars. NOTE: unusual (y, x) order, not (x, y)
// Computing max scroll:
let content_height = paragraph.line_count(area.width) as u16;
let viewport_height = content_area.height;
let max_scroll = content_height.saturating_sub(viewport_height);
// Clamping scroll:
scroll_offset = scroll_offset.min(max_scroll);
// Page scroll:
let page_size = viewport_height;
scroll_offset = scroll_offset.saturating_add(page_size).min(max_scroll); // PgDn
scroll_offset = scroll_offset.saturating_sub(page_size); // PgUp
Critical note on line_count: Paragraph::line_count(width) computes total lines accounting for word wrapping. Since the document lines are pre-rendered as discrete Line objects (one per logical line), and the content area has no wrap by default in Paragraph, line_count will equal lines.len() unless you enable Wrap. Keep wrapping disabled for pre-rendered markdown lines.
Pattern 5: Status Bar Layout
// Source: https://docs.rs/ratatui/0.30.0/ratatui/prelude/struct.Layout.html
// Split the frame: content area above, status bar at bottom
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(frame.area());
let content_area = chunks[0];
let status_area = chunks[1];
// Status bar: reverse video, filename left, hints right
let status = Paragraph::new(Line::from(vec![
Span::styled(
format!(" {} ", filename),
Style::default().add_modifier(Modifier::REVERSED),
),
Span::styled(
" q:Quit j/k:Scroll PgUp/PgDn:Page ",
Style::default().add_modifier(Modifier::REVERSED),
),
]))
.style(Style::default().add_modifier(Modifier::REVERSED));
frame.render_widget(status, status_area);
Pattern 6: Code Block Rendering with Rounded Borders
Code blocks use ratatui's Block with BorderType::Rounded (produces ╭╮╰╯):
// Source: https://docs.rs/ratatui/0.30.0/ratatui/widgets/struct.Block.html
use ratatui::widgets::{Block, BorderType, Borders};
// Emit a Block frame around the highlighted code
let code_block_border = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded) // ╭─╮ / ╰─╯ corners
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(lang, Style::default().fg(Color::Yellow)));
// The inner area of the block holds the Paragraph with highlighted lines
When building Vec<Line>, code blocks are NOT rendered inside a live Block widget (that would require nested rendering). Instead, emit the border characters as raw Span objects forming the box-drawing frame:
╭─ rust ──────────────────╮
│ let x = 1; │
│ println!("{}", x); │
╰─────────────────────────╯
This is simpler: each line of the code block becomes a Line with a leading │ span and a trailing │ span. The top/bottom borders are separate Line objects. This keeps everything in the flat Vec<Line> model and avoids nested widget rendering complexity.
Pattern 7: Syntax Highlighting with syntect + syntect-tui
// Source: https://docs.rs/syntect/latest/syntect/easy/struct.HighlightLines.html
// Source: syntect-tui 3.0 (into_span function)
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
// Load once at program start (not per-document):
lazy_static! {
static ref SYNTAX_SET: SyntaxSet = SyntaxSet::load_defaults_newlines();
static ref THEME_SET: ThemeSet = ThemeSet::load_defaults();
}
pub fn highlight_code(code: &str, lang: &str) -> Vec<Line<'static>> {
let syntax = SYNTAX_SET.find_syntax_by_token(lang)
.or_else(|| SYNTAX_SET.find_syntax_by_name(lang))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
// Use base16-ocean.dark as starting theme; map RGB to CGA afterwards
let mut highlighter = HighlightLines::new(
syntax,
&THEME_SET.themes["base16-ocean.dark"],
);
let mut result = Vec::new();
for line in code.lines() {
// highlight_line signature:
// pub fn highlight_line<'b>(&mut self, line: &'b str, syntax_set: &SyntaxSet)
// -> Result<Vec<(Style, &'b str)>, Error>
let ranges = highlighter.highlight_line(line, &SYNTAX_SET).unwrap_or_default();
let spans: Vec<Span<'static>> = ranges.into_iter()
.map(|(style, text)| {
let fg = syntect_color_to_cga(style.foreground);
Span::styled(
text.to_string(), // 'static via owned String
Style::default().fg(fg),
)
})
.collect();
result.push(Line::from(spans));
}
result
}
Note: syntect_tui::into_span() can be used instead of manual conversion, but it passes through RGB colors as Color::Rgb(r, g, b). For strict CGA 16-color output, the manual mapping function below is needed.
Pattern 8: RGB to CGA 16-Color Mapping
Syntect themes use RGB colors. Map to nearest CGA 16 color for terminal compatibility:
// Source: ratatui Color variants documented at
// https://docs.rs/ratatui/0.30.0/ratatui/style/enum.Color.html
use syntect::highlighting::Color as SyntectColor;
use ratatui::style::Color;
/// Map a syntect RGB color to the nearest CGA 16-color ratatui Color.
/// Uses Euclidean distance in RGB space.
pub fn syntect_color_to_cga(c: SyntectColor) -> Color {
// CGA 16 palette: (r, g, b, Color variant)
const CGA: &[(u8, u8, u8, Color)] = &[
(0, 0, 0, Color::Black),
(170, 0, 0, Color::Red),
(0, 170, 0, Color::Green),
(170, 170, 0, Color::Yellow),
(0, 0, 170, Color::Blue),
(170, 0, 170, Color::Magenta),
(0, 170, 170, Color::Cyan),
(170, 170, 170, Color::Gray),
(85, 85, 85, Color::DarkGray),
(255, 85, 85, Color::LightRed),
(85, 255, 85, Color::LightGreen),
(255, 255, 85, Color::LightYellow), // also Color::Yellow bright variant
(85, 85, 255, Color::LightBlue),
(255, 85, 255, Color::LightMagenta),
(85, 255, 255, Color::LightCyan),
(255, 255, 255, Color::White),
];
let mut best = Color::White;
let mut best_dist = u32::MAX;
for &(r, g, b, color) in CGA {
let dr = (c.r as i32 - r as i32).pow(2) as u32;
let dg = (c.g as i32 - g as i32).pow(2) as u32;
let db = (c.b as i32 - b as i32).pow(2) as u32;
let dist = dr + dg + db;
if dist < best_dist {
best_dist = dist;
best = color;
}
}
best
}
Alternative approach: Assign CGA colors by syntect scope name rather than RGB proximity. Scope names like keyword, string.quoted, comment can be mapped directly to desired CGA colors without going through RGB at all. This is more predictable for the BBS aesthetic but requires scope inspection from syntect's ScopeRegionIterator. For Phase 2, RGB proximity is simpler and sufficient.
Pattern 9: Terminal Resize Handling
// Source: https://ratatui.rs/faq/
// Source: https://docs.rs/ratatui/0.30.0/ratatui/struct.Terminal.html
// In the event loop (extending Phase 1 pattern):
match event::read()? {
Event::Key(key) => { self.handle_key(key); }
Event::Resize(_w, _h) => {
// For Viewport::Fullscreen: Terminal::draw() calls autoresize() internally.
// Buffer is resized automatically on next draw call.
// No explicit action needed — just let the next draw() handle it.
// HOWEVER: recompute max_scroll after resize since content_area.height changes.
// This happens naturally because max_scroll is computed inside draw().
}
_ => {}
}
Key finding: Viewport::Fullscreen + Terminal::draw() automatically resizes the internal buffer when terminal size changes. No explicit terminal.autoresize() call is needed. The draw closure receives a Frame whose frame.area() reflects the current terminal size. Scroll offset recomputation happens inside the draw closure naturally.
Pattern 10: GFM Table Two-Pass Rendering
Tables require knowing all column widths before emitting any lines:
// Phase 1: Collect all cells during event parsing
// Phase 2: Emit box-drawing grid
pub fn emit_table(
alignments: &[Alignment],
rows: &[Vec<String>], // rows[0] = header row
lines: &mut Vec<Line<'static>>,
) {
let n_cols = alignments.len();
// Compute column widths
let mut col_widths: Vec<usize> = vec![3; n_cols];
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < n_cols {
col_widths[i] = col_widths[i].max(cell.len() + 2); // padding
}
}
}
// Top border: ┌──────┬──────┐
let top = build_table_row('┌', '┬', '┐', '─', &col_widths);
lines.push(Line::from(Span::styled(top, Style::default().fg(Color::Cyan))));
// Header row with bold CGA styling
if let Some(header) = rows.first() {
let row_line = build_table_data_row(header, &col_widths, alignments,
Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD));
lines.push(row_line);
// Header separator: ├──────┼──────┤
let sep = build_table_row('├', '┼', '┤', '─', &col_widths);
lines.push(Line::from(Span::styled(sep, Style::default().fg(Color::Cyan))));
}
// Data rows
for row in rows.iter().skip(1) {
let row_line = build_table_data_row(row, &col_widths, alignments,
Style::default());
lines.push(row_line);
}
// Bottom border: └──────┴──────┘
let bottom = build_table_row('└', '┴', '┘', '─', &col_widths);
lines.push(Line::from(Span::styled(bottom, Style::default().fg(Color::Cyan))));
lines.push(Line::default()); // spacing after table
}
Pattern 11: File Loading and Error State
// Source: stdlib std::fs
pub enum VaultDocument {
Loaded { path: PathBuf, content: String },
Missing { path: PathBuf },
ReadError { path: PathBuf, reason: String },
}
pub fn load_document(vault_path: &Path, relative: &str) -> VaultDocument {
let full_path = vault_path.join(relative);
match std::fs::read_to_string(&full_path) {
Ok(content) => VaultDocument::Loaded { path: full_path, content },
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
VaultDocument::Missing { path: full_path }
}
Err(e) => VaultDocument::ReadError {
path: full_path,
reason: e.to_string(),
},
}
}
The missing index.md error screen is a custom ErrorScreenWidget that renders a BBS-styled box with box-drawing borders:
┌─────────────────────────────────────────┐
│ *** SYSTEM ERROR *** │
│ │
│ No index.md found in vault: │
│ /path/to/vault │
│ │
│ Create index.md to begin. │
└─────────────────────────────────────────┘
Anti-Patterns to Avoid
-
Lifetime-borrowing
Line<'a>from input markdown string: The markdown string goes out of scope or is reloaded. Always convert toLine<'static>(ownedStringin eachSpan) during rendering. This costs a small allocation but avoids pervasive lifetime annotations throughApp. -
Calling
SyntaxSet::load_defaults_newlines()per document: Loading the embedded syntax set takes ~23ms. Call it once at startup. Uselazy_static!or pass a&SyntaxSetreference. -
Forgetting
Options::ENABLE_TABLES: Without this flag,Parser::new_ext()will not emitTag::Tableevents. Table markdown is silently treated as plain text with pipe characters. Always set GFM extension flags explicitly. -
Not using
TextMergeStream: pulldown-cmark may split a single logical text run across multipleEvent::Textevents when inline formatting markers exist nearby (e.g., text before an emphasis marker).TextMergeStream::new(parser)coalesces adjacentTextevents before your handler sees them. -
Rendering live
Blockwidgets inside the lines Vec:Blockis a ratatui widget that requires aRectandBufferto render. You cannot embed it insideVec<Line>. All box-drawing for code blocks and tables must be emitted as raw character spans (│, ╭, ─, etc.) in the Line sequence. -
Scroll offset as
i32or signed: Useu16throughout.saturating_subhandles underflow.saturating_add+min(max)handles overflow. Paragraph::scroll() takes(u16, u16)— passingusizerequires explicit cast. -
Using
Paragraph::scroll()withWrapenabled: When Paragraph wraps lines,line_count()reflects wrap expansion, but the inputLineobjects do not change. The scroll offset applies to rendered lines, not input lines. For pre-rendered markdown (oneLineper logical line), disableWrapto keep scroll math simple and predictable.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Markdown parsing | Custom parser | pulldown-cmark 0.13.1 | CommonMark spec compliance, edge cases (nested emphasis, escaped chars, HTML entities) |
| Syntax tokenization | Keyword regex lists | syntect 5.3.0 | 80+ languages, Sublime text definitions, maintained separately |
| Color conversion (syntect→ratatui) | Manual RGB conversion | syntect-tui 3.0 into_span() |
Handles style flag mapping (bold, italic from theme); only override for CGA quantization |
| Table column widths | Screen-width allocation | Two-pass cell collection + max() | Tables need all cell content before first border char can be emitted |
Key insight: The pulldown-cmark event stream is a correct CommonMark parser; building on top of it is always correct. The only custom logic needed is converting events to ratatui Line objects — this is inherently application-specific and there is no general library that produces BBS-CGA-styled output.
Common Pitfalls
Pitfall 1: Widget Trait Signature Changed in 0.29/0.30
What goes wrong: Code written for ratatui <= 0.28 using WidgetRef directly fails to compile with 0.30. Code that impl WidgetRef for Foo instead of impl Widget for &Foo no longer works.
Why it happens: In 0.30, WidgetRef no longer has a blanket impl of Widget. The relationship inverted: impl Widget for &W is now the canonical pattern for immutable reference-based widgets.
How to avoid: Use impl Widget for &MyWidget (consuming a reference) for stored, reusable widgets. Use impl Widget for MyWidget (consuming ownership) for one-shot widgets created in the draw closure. Do NOT implement WidgetRef directly.
Warning signs: Compiler error "the trait Widget is not implemented for MyWidget" when passing a stored widget to frame.render_widget().
Pitfall 2: pulldown-cmark Table Events Not Firing
What goes wrong: Markdown tables render as plain text with pipe characters instead of structured tables.
Why it happens: Options::ENABLE_TABLES is not set. Tables are a GFM extension, not CommonMark. They require explicit opt-in.
How to avoid: Use Parser::new_ext(input, opts) with opts.insert(Options::ENABLE_TABLES). Verify with a test document containing a table.
Warning signs: | col1 | col2 | appears verbatim in output instead of a box-drawing grid.
Pitfall 3: Consecutive Text Events Splitting Styled Runs
What goes wrong: Text like Hello *world* there produces four separate Event::Text events — "Hello ", then inside the emphasis a "world" text, then "world" again, then " there" — causing broken styled spans.
Why it happens: pulldown-cmark's internal state machine may emit multiple adjacent Text events at inline formatting boundaries.
How to avoid: Wrap the parser in TextMergeStream::new(parser) before iterating events. This coalesces adjacent Text events into single events.
Warning signs: Text appears duplicated or styling bleeds into adjacent text.
Pitfall 4: Syntect Load on Every Document
What goes wrong: Loading SyntaxSet::load_defaults_newlines() takes ~23ms. If called on each document load, it adds perceptible lag on navigation.
Why it happens: The embedded syntax definitions are a ~200KB compressed binary blob that requires decompression and linking on each call.
How to avoid: Initialize SyntaxSet and ThemeSet once at program startup. Store in a module-level OnceLock<SyntaxSet> or pass as shared references. Do not call load_defaults_* inside document rendering loops.
Warning signs: Noticeable lag when switching documents (Phase 3 concern, but architecture must support it from Phase 2).
Pitfall 5: Scroll Math with Paragraph::scroll() is (y, x) Not (x, y)
What goes wrong: Passing (horizontal, vertical) produces sideways scrolling instead of vertical scrolling.
Why it happens: The Paragraph::scroll() method takes (y, x) — vertical first, horizontal second. This is the opposite of typical (x, y) coordinate convention. The ratatui documentation explicitly flags this as a deliberate deviation from crate convention.
How to avoid: Always write paragraph.scroll((self.scroll_offset, 0)) where the first element is vertical scroll and the second is always 0 (no horizontal scroll for a markdown reader).
Warning signs: Content scrolls horizontally but not vertically when pressing j/k.
Pitfall 6: Line<'static> vs Line<'a> Lifetime Confusion
What goes wrong: Storing Vec<Line<'a>> in App where 'a is tied to the markdown string lifetime causes the borrow checker to reject the code because App must outlive the borrowed string.
Why it happens: If Span content is borrowed &str from the markdown string, the Line borrows from that string. When the string is dropped or replaced, all the Line objects become invalid.
How to avoid: Always use .to_string() or .into_owned() when creating Span content during rendering: Span::styled(text.to_string(), style). This makes every Span own its content ('static). Vec<Line<'static>> can be stored in App without lifetime parameters.
Warning signs: Compiler error "cannot infer an appropriate lifetime for borrow expression" in the renderer function.
Pitfall 7: Missing index.md Should NOT Panic
What goes wrong: std::fs::read_to_string(vault_path.join("index.md")).unwrap() panics if index.md is missing, leaving the terminal in raw mode.
Why it happens: Missing files are an expected user-facing state (new vault), not a bug. unwrap() on Result converts expected errors into panics.
How to avoid: Use match or .map_err() to convert io::Error to a VaultDocument::Missing state. The App then renders the BBS error screen widget instead of a document widget. Never unwrap file I/O operations.
Warning signs: Terminal stuck in raw mode after starting with an empty vault directory.
Code Examples
Verified patterns from official sources:
CGA Color Assignments for Headings (Claude's Discretion)
Based on CGA palette and BBS aesthetic — assigned by researcher:
| Level | Color | Decorator |
|---|---|---|
| H1 | Color::LightCyan + Modifier::BOLD |
══════ (double-line, full width) |
| H2 | Color::LightYellow + Modifier::BOLD |
────── (single-line, full width) |
| H3 | Color::LightGreen + Modifier::BOLD |
none (color only) |
| H4 | Color::Green |
none |
| H5 | Color::Cyan |
none |
| H6 | Color::DarkGray |
none |
CGA Colors for Syntax Highlighting (Claude's Discretion)
For syntax scopes mapped to CGA colors:
| Scope Category | CGA Color |
|---|---|
Keywords (fn, let, if, etc.) |
Color::LightCyan |
| Strings | Color::LightGreen |
| Comments | Color::DarkGray |
| Numbers | Color::LightYellow |
| Types/classes | Color::Yellow |
| Functions/methods | Color::LightMagenta |
| Operators | Color::Gray |
| Default/text | Color::Gray |
Blockquote Style (Claude's Discretion)
│ This is a blockquote line
│ with a second line.
- Left border
│inColor::Yellow - Content in
Color::Gray(slightly dimmed from default) - One blank line before and after
Horizontal Rule Style (Claude's Discretion)
────────────────────────────────────────
Full-width ─ characters in Color::DarkGray. One blank line before and after.
Image Placeholder Style (Claude's Discretion)
[IMAGE: alt text here]
Color::DarkGray + Modifier::DIM. Surrounded by blank lines if block-level.
List Bullet Style (Claude's Discretion)
- Unordered:
•inColor::Cyan, content in default color - Ordered:
1.inColor::Cyan, content in default color - Nested (each level adds 2 spaces):
◦for depth 2,▪for depth 3
Scroll Boundary Behavior (Claude's Discretion)
Stop at boundaries (not bounce): scroll_offset = scroll_offset.saturating_sub(1) for up, scroll_offset = (scroll_offset + 1).min(max_scroll) for down. Scroll stops silently at top/bottom — no visual indicator needed in Phase 2.
Complete pulldown-cmark Parser Setup
// Source: https://docs.rs/pulldown-cmark/latest/pulldown_cmark/
use pulldown_cmark::{Options, Parser, TextMergeStream};
pub fn make_parser(input: &str) -> impl Iterator<Item = pulldown_cmark::Event<'_>> {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_GFM); // blockquote alert syntax
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES); // {#id .class} on headings
TextMergeStream::new(Parser::new_ext(input, opts))
}
syntect Initialization (Once at Startup)
// Source: https://docs.rs/syntect/latest/syntect/
use std::sync::OnceLock;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
pub fn init_highlighter() {
SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines);
THEME_SET.get_or_init(ThemeSet::load_defaults);
}
pub fn syntax_set() -> &'static SyntaxSet {
SYNTAX_SET.get().expect("init_highlighter() not called")
}
pub fn theme_set() -> &'static ThemeSet {
THEME_SET.get().expect("init_highlighter() not called")
}
App State Extension for Phase 2
// Extending Phase 1 App struct
pub struct App {
// Phase 1 fields (unchanged)
is_login_shell: bool,
ctrl_c_pressed_at: Option<Instant>,
show_quit_prompt: bool,
should_quit: bool,
config: Config,
// Phase 2 additions
document: DocumentState,
scroll_offset: u16,
}
pub enum DocumentState {
Rendering, // initial state during parse
Loaded {
filename: String,
lines: Vec<Line<'static>>,
},
Missing {
path: PathBuf,
},
Error {
path: PathBuf,
reason: String,
},
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
impl WidgetRef for Foo |
impl Widget for &Foo |
ratatui 0.29/0.30 (2024-2025) | Previous WidgetRef impl pattern no longer compiles; must update if migrating from older ratatui |
Parser::new() only |
Parser::new_ext(input, opts) with Options flags |
pulldown-cmark 0.9+ | Extensions like tables require explicit opt-in via Options struct |
| syntect with Oniguruma C lib | default-features = false, features = ["default-fancy"] |
syntect 4.0 (2022) | Avoids C compilation; pure-Rust regex engine; simpler build on Linux servers |
| Manual scroll state management | Paragraph::scroll((y, 0)) |
ratatui stable | Paragraph handles the offset; app only needs to track the u16 offset value |
Deprecated/outdated:
tui-rs: The predecessor crate to ratatui. All docs usingtui::widgets::*are from this era. Not applicable.syntect::highlighting::HighlightIterator: The lower-level iterator API.easy::HighlightLinesis the preferred high-level wrapper.pulldown_cmark::html::push_html: The HTML output function. Phase 2 never produces HTML; ignore this API.
Open Questions
-
syntect-tui 3.0 ratatui 0.30 compatibility
- What we know: syntect-tui 3.0 targets ratatui. The crate list shows syntect-tui 3.0 most recently updated.
- What's unclear: Whether syntect-tui 3.0 has been tested specifically against ratatui 0.30.0. The Widget trait change in 0.30 only affects widget implementations, not
Span/Styletypes which syntect-tui converts. The risk is LOW. - Recommendation: Add
syntect-tui = "3.0"andsyntect = "5.3"to Cargo.toml. If compilation fails due to ratatui version mismatch, use manual syntect-style-to-ratatui-style conversion (the Pattern 8 RGB-to-CGA function handles the whole pipeline anyway).
-
Horizontal rule width at parse time
- What we know:
Event::Rulefires during markdown parsing before we know the terminal width. Full-width─────requires knowingarea.widthwhich is only available insideframe.render_widget(). - What's unclear: Best approach to defer width-dependent rendering.
- Recommendation: Emit a sentinel
Linewith a special marker during parsing (e.g.,Line::from(Span::raw("--- RULE ---"))). In the widget'srender()method, detect this sentinel and replace it with"─".repeat(area.width). Alternatively, emit a fixed 80-char rule and accept that it won't be exact width.
- What we know:
-
Table rendering width constraint
- What we know: Tables with many wide columns may exceed terminal width. The two-pass approach computes column widths from content, not from available terminal width.
- What's unclear: Whether to truncate, wrap, or overflow table cells.
- Recommendation: For Phase 2, emit tables at natural content width and let the terminal clip. Horizontal scrolling is not required (NAV-05 only specifies vertical scroll). Address overflow in Phase 3 if reported.
-
OnceLock for syntect vs. passing as parameter
- What we know:
OnceLockis in stablestdsince Rust 1.70. The project uses edition 2024. - What's unclear: Whether to initialize
SyntaxSet/ThemeSetinmain.rsand pass down, or use module-levelOnceLock. - Recommendation: Initialize in
main.rsbeforeApp::new(), storeSyntaxSetinApp. Avoids global state and is more testable.SyntaxSetisSend + Syncso storing in App is fine.
- What we know:
Sources
Primary (HIGH confidence)
https://docs.rs/pulldown-cmark/latest/pulldown_cmark/enum.Event.html— 13 Event variants documented, including all Phase 2 relevant typeshttps://docs.rs/pulldown-cmark/latest/pulldown_cmark/enum.Tag.html— Complete Tag enum: Heading{level, id, classes, attrs}, List(Option), CodeBlock(CodeBlockKind), Table(Vec), TableHead, TableRow, TableCellhttps://docs.rs/pulldown-cmark/latest/pulldown_cmark/struct.Options.html— 15 Options flags documented, including ENABLE_TABLES, ENABLE_STRIKETHROUGH, ENABLE_TASKLISTS, ENABLE_GFMhttps://docs.rs/ratatui/0.30.0/ratatui/widgets/trait.Widget.html— Verified Widget trait:fn render(self, area: Rect, buf: &mut Buffer) where Self: Sizedhttps://docs.rs/ratatui/0.30.0/ratatui/widgets/struct.Paragraph.html— scroll((y, x): (u16, u16)), line_count(width: u16) -> usizehttps://docs.rs/ratatui/0.30.0/ratatui/style/struct.Style.html— fg(), bg(), add_modifier(), Modifier constants: BOLD, ITALIC, DIM, UNDERLINED, REVERSEDhttps://docs.rs/ratatui/0.30.0/ratatui/style/enum.Color.html— Named colors: Black, Red, Green, Yellow, Blue, Magenta, Cyan, Gray, DarkGray, LightRed, LightGreen, LightYellow, LightBlue, LightMagenta, LightCyan, White, Resethttps://docs.rs/ratatui/0.30.0/ratatui/widgets/struct.Block.html— BorderType::Rounded produces ╭╮╰╯ cornershttps://ratatui.rs/highlights/v030/— Breaking change: WidgetRef no longer blanket-implements Widget; useimpl Widget for &Foopatternhttps://docs.rs/syntect/latest/syntect/easy/struct.HighlightLines.html— highlight_line<'b>(&mut self, line: &'b str, &SyntaxSet) -> Result<Vec<(Style, &'b str)>, Error>https://docs.rs/syntect/latest/syntect/— Version 5.3.0 confirmed; embedded syntax set ~200KB; load time ~23mshttps://ratatui.rs/faq/— Resize: Event::Resize captured via crossterm; Viewport::Fullscreen autoresizes buffer in draw()
Secondary (MEDIUM confidence)
https://github.com/chanq-io/syntect-tui— syntect-tui 3.0:into_span()converts(syntect::Style, &str)toratatui::Span; MIT licensehttps://ratatui.rs/recipes/widgets/custom/— Widget impl patterns for 0.30: consuming vs reference-basedhttps://github.com/joshka/tui-markdown— Experimental PoC; not suitable for BBS aesthetic without heavy forking; reference only
Tertiary (LOW confidence, flag for validation)
- syntect-tui 3.0 compatibility with ratatui 0.30.0 — not explicitly verified against 0.30; Style/Span API compatibility inferred from no known breaking changes in those types between 0.28 and 0.30
- CGA color assignments for syntax scopes (keywords, strings, etc.) — researcher recommendation based on classic BBS aesthetics, not from an authoritative source; adjust during implementation
Metadata
Confidence breakdown:
- Standard stack: HIGH — all versions verified via docs.rs; pulldown-cmark 0.13.1, ratatui 0.30.0, syntect 5.3.0 confirmed
- Architecture: HIGH — Widget trait signature verified from official ratatui 0.30 docs; pulldown-cmark Event and Tag enums verified; Paragraph::scroll() API verified
- Pitfalls: HIGH for ratatui 0.30 Widget breaking change (confirmed from release notes); HIGH for pulldown-cmark Options flags; MEDIUM for syntect-tui compatibility
- CGA color assignments: LOW — researcher recommendation, no authoritative source; safe to adjust during implementation
Research date: 2026-02-28 Valid until: 2026-03-30 (stable libraries; pulldown-cmark and ratatui move slowly)