docs(02): research vault core and rendering phase
This commit is contained in:
@@ -0,0 +1,999 @@
|
|||||||
|
# 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<u64>), 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<Alignment>) 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:**
|
||||||
|
```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<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.
|
||||||
|
|
||||||
|
```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<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:**
|
||||||
|
```rust
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```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<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:
|
||||||
|
|
||||||
|
```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<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
|
||||||
|
|
||||||
|
```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<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
|
||||||
|
|
||||||
|
```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<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:
|
||||||
|
|
||||||
|
```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<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
|
||||||
|
|
||||||
|
```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<Line>`. 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<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 `│` 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<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)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 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 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<u64>), CodeBlock(CodeBlockKind), Table(Vec<Alignment>), 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<Vec<(Style, &'b str)>, 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)
|
||||||
Reference in New Issue
Block a user