# 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 (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 ## 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 | --- ## 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` (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` 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>` (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:** ```toml [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 conversion pipeline └── highlighter.rs # NEW: syntect integration, CGA color mapping ``` ### Pattern 1: Markdown-to-Lines Conversion Pipeline **What:** Convert a markdown string to `Vec>` 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. ```rust // 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>, // Internal state during parsing current_spans: Vec>, current_style: Style, in_code_block: bool, code_lang: String, code_buf: String, list_depth: u32, list_counters: Vec>, // None=unordered, Some(n)=ordered at n in_table: bool, table_columns: Vec, table_rows: Vec>, // raw cell text (two-pass for tables) in_table_head: bool, } ``` **Event loop skeleton:** ```rust // Source: https://docs.rs/pulldown-cmark/latest/pulldown_cmark/ pub fn render_markdown(input: &str) -> Vec> { 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: ```rust // 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` not `Option`. 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: ```rust // 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: ```rust // Source: https://ratatui.rs/recipes/widgets/custom/ pub struct DocumentWidget { lines: Vec>, 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 ```rust // 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 ```rust // 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 ╭╮╰╯): ```rust // 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`, 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` model and avoids nested widget rendering complexity. ### Pattern 7: Syntax Highlighting with syntect + syntect-tui ```rust // 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> { 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, Error> let ranges = highlighter.highlight_line(line, &SYNTAX_SET).unwrap_or_default(); let spans: Vec> = 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: ```rust // 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 ```rust // 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: ```rust // Phase 1: Collect all cells during event parsing // Phase 2: Emit box-drawing grid pub fn emit_table( alignments: &[Alignment], rows: &[Vec], // rows[0] = header row lines: &mut Vec>, ) { let n_cols = alignments.len(); // Compute column widths let mut col_widths: Vec = 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 ```rust // 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 to `Line<'static>` (owned `String` in each `Span`) during rendering. This costs a small allocation but avoids pervasive lifetime annotations through `App`. - **Calling `SyntaxSet::load_defaults_newlines()` per document:** Loading the embedded syntax set takes ~23ms. Call it once at startup. Use `lazy_static!` or pass a `&SyntaxSet` reference. - **Forgetting `Options::ENABLE_TABLES`:** Without this flag, `Parser::new_ext()` will not emit `Tag::Table` events. 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 multiple `Event::Text` events when inline formatting markers exist nearby (e.g., text before an emphasis marker). `TextMergeStream::new(parser)` coalesces adjacent `Text` events before your handler sees them. - **Rendering live `Block` widgets inside the lines Vec:** `Block` is a ratatui widget that requires a `Rect` and `Buffer` to render. You cannot embed it inside `Vec`. All box-drawing for code blocks and tables must be emitted as raw character spans (│, ╭, ─, etc.) in the Line sequence. - **Scroll offset as `i32` or signed:** Use `u16` throughout. `saturating_sub` handles underflow. `saturating_add` + `min(max)` handles overflow. Paragraph::scroll() takes `(u16, u16)` — passing `usize` requires explicit cast. - **Using `Paragraph::scroll()` with `Wrap` enabled:** When Paragraph wraps lines, `line_count()` reflects wrap expansion, but the input `Line` objects do not change. The scroll offset applies to rendered lines, not input lines. For pre-rendered markdown (one `Line` per logical line), disable `Wrap` to 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` 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>` 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>` 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 `│` in `Color::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: ` • ` in `Color::Cyan`, content in default color - Ordered: ` 1. ` in `Color::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 ```rust // Source: https://docs.rs/pulldown-cmark/latest/pulldown_cmark/ use pulldown_cmark::{Options, Parser, TextMergeStream}; pub fn make_parser(input: &str) -> impl Iterator> { 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) ```rust // Source: https://docs.rs/syntect/latest/syntect/ use std::sync::OnceLock; use syntect::highlighting::ThemeSet; use syntect::parsing::SyntaxSet; static SYNTAX_SET: OnceLock = OnceLock::new(); static THEME_SET: OnceLock = 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 ```rust // Extending Phase 1 App struct pub struct App { // Phase 1 fields (unchanged) is_login_shell: bool, ctrl_c_pressed_at: Option, 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>, }, 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 using `tui::widgets::*` are from this era. Not applicable. - `syntect::highlighting::HighlightIterator`: The lower-level iterator API. `easy::HighlightLines` is the preferred high-level wrapper. - `pulldown_cmark::html::push_html`: The HTML output function. Phase 2 never produces HTML; ignore this API. --- ## Open Questions 1. **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`/`Style` types which syntect-tui converts. The risk is LOW. - Recommendation: Add `syntect-tui = "3.0"` and `syntect = "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). 2. **Horizontal rule width at parse time** - What we know: `Event::Rule` fires during markdown parsing before we know the terminal width. Full-width `─────` requires knowing `area.width` which is only available inside `frame.render_widget()`. - What's unclear: Best approach to defer width-dependent rendering. - Recommendation: Emit a sentinel `Line` with a special marker during parsing (e.g., `Line::from(Span::raw("--- RULE ---"))`). In the widget's `render()` 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. 3. **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. 4. **OnceLock for syntect vs. passing as parameter** - What we know: `OnceLock` is in stable `std` since Rust 1.70. The project uses edition 2024. - What's unclear: Whether to initialize `SyntaxSet`/`ThemeSet` in `main.rs` and pass down, or use module-level `OnceLock`. - Recommendation: Initialize in `main.rs` before `App::new()`, store `SyntaxSet` in `App`. Avoids global state and is more testable. `SyntaxSet` is `Send + Sync` so storing in App is fine. --- ## Sources ### Primary (HIGH confidence) - `https://docs.rs/pulldown-cmark/latest/pulldown_cmark/enum.Event.html` — 13 Event variants documented, including all Phase 2 relevant types - `https://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, TableCell - `https://docs.rs/pulldown-cmark/latest/pulldown_cmark/struct.Options.html` — 15 Options flags documented, including ENABLE_TABLES, ENABLE_STRIKETHROUGH, ENABLE_TASKLISTS, ENABLE_GFM - `https://docs.rs/ratatui/0.30.0/ratatui/widgets/trait.Widget.html` — Verified Widget trait: `fn render(self, area: Rect, buf: &mut Buffer) where Self: Sized` - `https://docs.rs/ratatui/0.30.0/ratatui/widgets/struct.Paragraph.html` — scroll((y, x): (u16, u16)), line_count(width: u16) -> usize - `https://docs.rs/ratatui/0.30.0/ratatui/style/struct.Style.html` — fg(), bg(), add_modifier(), Modifier constants: BOLD, ITALIC, DIM, UNDERLINED, REVERSED - `https://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, Reset - `https://docs.rs/ratatui/0.30.0/ratatui/widgets/struct.Block.html` — BorderType::Rounded produces ╭╮╰╯ corners - `https://ratatui.rs/highlights/v030/` — Breaking change: WidgetRef no longer blanket-implements Widget; use `impl Widget for &Foo` pattern - `https://docs.rs/syntect/latest/syntect/easy/struct.HighlightLines.html` — highlight_line<'b>(&mut self, line: &'b str, &SyntaxSet) -> Result, Error> - `https://docs.rs/syntect/latest/syntect/` — Version 5.3.0 confirmed; embedded syntax set ~200KB; load time ~23ms - `https://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)` to `ratatui::Span`; MIT license - `https://ratatui.rs/recipes/widgets/custom/` — Widget impl patterns for 0.30: consuming vs reference-based - `https://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)