docs(02): create phase plan
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
---
|
||||
phase: 02-vault-core-and-rendering
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- Cargo.toml
|
||||
- src/vault.rs
|
||||
- src/highlighter.rs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- REND-03
|
||||
- REND-10
|
||||
- NAV-06
|
||||
- NAV-07
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "pulldown-cmark, syntect, and syntect-tui compile without errors alongside existing ratatui 0.30"
|
||||
- "vault.rs can load a markdown file from a vault path and return structured success/missing/error states"
|
||||
- "highlighter.rs initializes syntect once and can highlight a code string into Vec<Line<'static>> with CGA 16-color palette"
|
||||
artifacts:
|
||||
- path: "Cargo.toml"
|
||||
provides: "pulldown-cmark, syntect, syntect-tui dependencies"
|
||||
contains: "pulldown-cmark"
|
||||
- path: "src/vault.rs"
|
||||
provides: "VaultDocument enum and load_document function"
|
||||
contains: "VaultDocument"
|
||||
- path: "src/highlighter.rs"
|
||||
provides: "syntect initialization, CGA color mapping, highlight_code function"
|
||||
contains: "syntect_color_to_cga"
|
||||
key_links:
|
||||
- from: "src/highlighter.rs"
|
||||
to: "syntect"
|
||||
via: "OnceLock<SyntaxSet> and OnceLock<ThemeSet> initialized once"
|
||||
pattern: "OnceLock.*SyntaxSet"
|
||||
- from: "src/vault.rs"
|
||||
to: "std::fs"
|
||||
via: "read_to_string for markdown file loading"
|
||||
pattern: "read_to_string"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add Phase 2 dependencies (pulldown-cmark, syntect, syntect-tui) to Cargo.toml, create the vault file-loading module, and create the syntax highlighter module with CGA 16-color mapping.
|
||||
|
||||
Purpose: Establish the foundation libraries and I/O layer that the markdown renderer (Plan 02) and app integration (Plan 03) build upon.
|
||||
Output: Three files modified/created; `cargo build` succeeds with all new dependencies.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-vault-core-and-rendering/02-RESEARCH.md
|
||||
@.planning/phases/02-vault-core-and-rendering/02-CONTEXT.md
|
||||
@src/config.rs
|
||||
@Cargo.toml
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add dependencies and create vault file-loading module</name>
|
||||
<files>Cargo.toml, src/vault.rs</files>
|
||||
<action>
|
||||
**Cargo.toml:** Add three new dependencies alongside the existing ones:
|
||||
```toml
|
||||
pulldown-cmark = "0.13.1"
|
||||
syntect = { version = "5.3", default-features = false, features = ["default-fancy"] }
|
||||
syntect-tui = "3.0"
|
||||
```
|
||||
|
||||
Note: `default-features = false, features = ["default-fancy"]` avoids the Oniguruma C library — pure Rust regex engine for clean SSH server builds.
|
||||
|
||||
**src/vault.rs:** Create a new module with:
|
||||
|
||||
1. `VaultDocument` enum with three variants:
|
||||
- `Loaded { path: PathBuf, content: String }` — file read successfully
|
||||
- `Missing { path: PathBuf }` — file does not exist (io::ErrorKind::NotFound)
|
||||
- `ReadError { path: PathBuf, reason: String }` — other I/O error
|
||||
|
||||
2. `load_document(vault_path: &Path, relative: &str) -> VaultDocument`:
|
||||
- Join `vault_path` with `relative` to get the full path
|
||||
- Use `std::fs::read_to_string()` with proper match on the Result
|
||||
- Map `ErrorKind::NotFound` to `VaultDocument::Missing`
|
||||
- Map other errors to `VaultDocument::ReadError`
|
||||
- Return `VaultDocument::Loaded` on success
|
||||
|
||||
The function takes `vault_path` (from Config) and a relative filename (e.g., "index.md").
|
||||
|
||||
Do NOT register the module in main.rs yet — Plan 03 handles wiring.
|
||||
</action>
|
||||
<verify>`cargo check` passes with no errors (new deps resolve, vault.rs compiles). Run `cargo check 2>&1` and confirm exit code 0.</verify>
|
||||
<done>Cargo.toml has pulldown-cmark 0.13.1, syntect 5.3 with default-fancy, and syntect-tui 3.0. vault.rs exists with VaultDocument enum and load_document function. `cargo check` succeeds.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create syntax highlighter module with CGA color mapping</name>
|
||||
<files>src/highlighter.rs</files>
|
||||
<action>
|
||||
Create `src/highlighter.rs` with these components:
|
||||
|
||||
1. **Module-level OnceLock statics** for one-time initialization:
|
||||
```rust
|
||||
static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
|
||||
static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
|
||||
```
|
||||
|
||||
2. **`init_highlighter()`** — call once from main.rs before App::new():
|
||||
- `SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines);`
|
||||
- `THEME_SET.get_or_init(ThemeSet::load_defaults);`
|
||||
|
||||
3. **`syntax_set() -> &'static SyntaxSet`** and **`theme_set() -> &'static ThemeSet`** accessor functions that call `.get().expect(...)`.
|
||||
|
||||
4. **`syntect_color_to_cga(c: syntect::highlighting::Color) -> ratatui::style::Color`** — RGB-to-CGA Euclidean distance mapper using the 16 CGA palette entries exactly as documented in the research (Pattern 8). The CGA palette entries:
|
||||
- Black(0,0,0), Red(170,0,0), Green(0,170,0), Yellow(170,170,0), Blue(0,0,170), Magenta(170,0,170), Cyan(0,170,170), Gray(170,170,170)
|
||||
- DarkGray(85,85,85), LightRed(255,85,85), LightGreen(85,255,85), LightYellow(255,255,85), LightBlue(85,85,255), LightMagenta(255,85,255), LightCyan(85,255,255), White(255,255,255)
|
||||
|
||||
5. **`highlight_code(code: &str, lang: &str) -> Vec<Line<'static>>`** — the public API for code block highlighting:
|
||||
- Look up syntax by token first, then by name, fall back to plain text
|
||||
- Use `HighlightLines::new(syntax, &theme_set().themes["base16-ocean.dark"])`
|
||||
- For each line in `code.lines()`: call `highlight_line()`, map each `(Style, &str)` tuple to a `Span::styled(text.to_string(), Style::default().fg(syntect_color_to_cga(style.foreground)))`, collect into `Line::from(spans)`
|
||||
- Return `Vec<Line<'static>>` (all owned strings, no lifetime leakage)
|
||||
|
||||
Do NOT register the module in main.rs yet — Plan 03 handles wiring. But do ensure the file compiles in isolation (no unresolved imports that depend on other new modules).
|
||||
</action>
|
||||
<verify>`cargo check` passes. Verify highlighter.rs compiles by temporarily adding `mod highlighter;` to main.rs, running `cargo check`, then removing it. Or simply verify the file has no syntax errors by checking `cargo check` output.</verify>
|
||||
<done>highlighter.rs exists with init_highlighter(), syntax_set(), theme_set(), syntect_color_to_cga(), and highlight_code() functions. All use OnceLock for one-time init. Output is Vec<Line<'static>> with CGA 16-color palette.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `cargo build` succeeds with all new dependencies
|
||||
2. `src/vault.rs` contains `VaultDocument` enum with `Loaded`, `Missing`, `ReadError` variants
|
||||
3. `src/vault.rs` contains `load_document()` function
|
||||
4. `src/highlighter.rs` contains `init_highlighter()`, `highlight_code()`, `syntect_color_to_cga()`
|
||||
5. No changes to existing Phase 1 files (app.rs, main.rs, config.rs, signals.rs, terminal.rs) other than Cargo.toml
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `cargo build` compiles cleanly with pulldown-cmark 0.13.1, syntect 5.3, and syntect-tui 3.0
|
||||
- vault.rs provides file loading with structured error states (Loaded/Missing/ReadError)
|
||||
- highlighter.rs provides one-shot syntect initialization and CGA-mapped syntax highlighting
|
||||
- Both new modules produce `'static` lifetime outputs (no borrowed data leaking)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-vault-core-and-rendering/02-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
phase: 02-vault-core-and-rendering
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "02-01"
|
||||
files_modified:
|
||||
- src/renderer.rs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- REND-01
|
||||
- REND-02
|
||||
- REND-03
|
||||
- REND-04
|
||||
- REND-05
|
||||
- REND-06
|
||||
- REND-07
|
||||
- REND-08
|
||||
- REND-09
|
||||
- REND-10
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Headings H1-H6 render with distinct CGA colors and decorators (H1=LightCyan+══, H2=LightYellow+──, H3-H6 color-only)"
|
||||
- "Bold text uses Modifier::BOLD, italic uses Modifier::ITALIC, inline code uses LightCyan styling"
|
||||
- "Fenced code blocks render with rounded box-drawing borders (╭─╮│╰─╯) and syntax-highlighted content via highlighter.rs"
|
||||
- "Ordered and unordered lists render with bullets/numbers and proper indentation per nesting depth"
|
||||
- "Blockquotes render with a yellow │ left border and gray content"
|
||||
- "Horizontal rules render as full-width ─── lines in DarkGray"
|
||||
- "Images render as [IMAGE: alt text] placeholders in DarkGray+DIM"
|
||||
- "GFM tables render with full box-drawing grid (┌┬┐├┼┤└┴┘) and bold LightCyan header row"
|
||||
artifacts:
|
||||
- path: "src/renderer.rs"
|
||||
provides: "Complete markdown-to-styled-lines conversion pipeline"
|
||||
exports: ["render_markdown"]
|
||||
min_lines: 200
|
||||
key_links:
|
||||
- from: "src/renderer.rs"
|
||||
to: "pulldown-cmark"
|
||||
via: "Parser::new_ext with TextMergeStream consuming all Event variants"
|
||||
pattern: "TextMergeStream"
|
||||
- from: "src/renderer.rs"
|
||||
to: "src/highlighter.rs"
|
||||
via: "highlight_code() called for fenced code blocks"
|
||||
pattern: "highlight_code"
|
||||
- from: "src/renderer.rs"
|
||||
to: "ratatui::text"
|
||||
via: "Produces Vec<Line<'static>> with Span styling"
|
||||
pattern: "Vec<Line<'static>>"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the complete markdown-to-styled-lines renderer that converts a markdown string into `Vec<Line<'static>>` using pulldown-cmark event processing, CGA-themed styling, syntax-highlighted code blocks, and box-drawing table grids.
|
||||
|
||||
Purpose: This is the core content pipeline — every markdown construct the user sees flows through this module.
|
||||
Output: `src/renderer.rs` with a public `render_markdown(input: &str, width: u16) -> Vec<Line<'static>>` function.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-vault-core-and-rendering/02-RESEARCH.md
|
||||
@.planning/phases/02-vault-core-and-rendering/02-CONTEXT.md
|
||||
@.planning/phases/02-vault-core-and-rendering/02-01-SUMMARY.md
|
||||
@src/highlighter.rs
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Build core markdown renderer with inline and block elements</name>
|
||||
<files>src/renderer.rs</files>
|
||||
<action>
|
||||
Create `src/renderer.rs` with the public function `render_markdown(input: &str, width: u16) -> Vec<Line<'static>>`.
|
||||
|
||||
The `width` parameter is needed for horizontal rules (full-width `─` repeats) and could be used for code block border sizing.
|
||||
|
||||
**Internal state struct (private):**
|
||||
|
||||
```rust
|
||||
struct RenderState {
|
||||
lines: Vec<Line<'static>>,
|
||||
current_spans: Vec<Span<'static>>,
|
||||
style_stack: Vec<Style>, // push on Start, pop on End
|
||||
in_code_block: bool,
|
||||
code_lang: String,
|
||||
code_buf: String,
|
||||
list_counters: Vec<Option<u64>>, // None=unordered, Some(n)=ordered from n
|
||||
in_blockquote: bool,
|
||||
blockquote_depth: u32,
|
||||
in_image: bool,
|
||||
image_alt: String,
|
||||
// Table state
|
||||
in_table: bool,
|
||||
table_alignments: Vec<pulldown_cmark::Alignment>,
|
||||
table_rows: Vec<Vec<String>>,
|
||||
current_cell: String,
|
||||
in_table_head: bool,
|
||||
// Width for rules
|
||||
width: u16,
|
||||
}
|
||||
```
|
||||
|
||||
**Parser setup:**
|
||||
- Use `Options::empty()` then insert `ENABLE_TABLES`, `ENABLE_STRIKETHROUGH`, `ENABLE_TASKLISTS`
|
||||
- Wrap parser in `TextMergeStream::new(Parser::new_ext(input, opts))`
|
||||
- Iterate events, dispatch to handler methods on RenderState
|
||||
|
||||
**Element handling (implement ALL of these):**
|
||||
|
||||
**Headings (REND-01, REND-10):**
|
||||
- On `Start(Tag::Heading { level, .. })`: push heading style onto style_stack based on level:
|
||||
- H1: `Color::LightCyan + Modifier::BOLD`
|
||||
- H2: `Color::LightYellow + Modifier::BOLD`
|
||||
- H3: `Color::LightGreen + Modifier::BOLD`
|
||||
- H4: `Color::Green`
|
||||
- H5: `Color::Cyan`
|
||||
- H6: `Color::DarkGray`
|
||||
- On `End(TagEnd::Heading(level))`: flush current_spans as a Line, then emit decorator:
|
||||
- H1: Line of `═` repeated to width, in `Color::LightCyan`
|
||||
- H2: Line of `─` repeated to width, in `Color::LightYellow`
|
||||
- H3-H6: no decorator line
|
||||
- Always add an empty Line after for spacing
|
||||
|
||||
**Paragraphs:**
|
||||
- On `Start(Tag::Paragraph)`: nothing (spans accumulate)
|
||||
- On `End(TagEnd::Paragraph)`: flush current_spans to a Line, push empty Line for spacing
|
||||
- Special handling: if inside blockquote, prepend blockquote decorators to each flushed line
|
||||
|
||||
**Inline formatting (REND-02):**
|
||||
- `Start(Tag::Strong)`: push current style with `Modifier::BOLD` added onto style_stack
|
||||
- `End(TagEnd::Strong)`: pop style_stack
|
||||
- `Start(Tag::Emphasis)`: push current style with `Modifier::ITALIC` added
|
||||
- `End(TagEnd::Emphasis)`: pop style_stack
|
||||
- `Event::Code(text)`: push Span with `Style::default().fg(Color::LightCyan)` — inline code
|
||||
|
||||
**Text:**
|
||||
- `Event::Text(text)`: if `in_code_block`, append to `code_buf`; if `in_image`, append to `image_alt`; if `in_table` and inside a cell, append to `current_cell`; otherwise push `Span::styled(text.to_string(), current_style())` to current_spans
|
||||
- `current_style()` returns the top of `style_stack`, or `Style::default()` if empty
|
||||
|
||||
**Soft/Hard breaks:**
|
||||
- `Event::SoftBreak`: push `Span::raw(" ")`
|
||||
- `Event::HardBreak`: flush current_spans as a Line
|
||||
|
||||
**Lists (REND-04):**
|
||||
- `Start(Tag::List(start))`: push `start` onto `list_counters`
|
||||
- `End(TagEnd::List(_))`: pop `list_counters`, push empty Line for spacing after top-level list
|
||||
- `Start(Tag::Item)`: compute indent = `" ".repeat(list_counters.len())`. Compute bullet:
|
||||
- If `list_counters.last() == Some(None)`: use `"• "` (depth 1), `"◦ "` (depth 2), `"▪ "` (depth 3+)
|
||||
- If `list_counters.last() == Some(Some(n))`: use `format!("{}. ", n)`, then increment n in the counter
|
||||
- Push indent + bullet as a Span in `Color::Cyan` to current_spans
|
||||
- `End(TagEnd::Item)`: flush current_spans as a Line
|
||||
|
||||
**Blockquotes (REND-05):**
|
||||
- `Start(Tag::BlockQuote(_))`: increment blockquote_depth, set in_blockquote = true, push empty line before
|
||||
- `End(TagEnd::BlockQuote(_))`: decrement blockquote_depth, set in_blockquote = false if depth == 0, push empty line after
|
||||
- When flushing lines inside blockquote: prepend `Span::styled("│ ".repeat(depth), Style::default().fg(Color::Yellow))` to each Line, and apply `Color::Gray` to the content spans
|
||||
|
||||
**Horizontal rules (REND-06):**
|
||||
- `Event::Rule`: push `Line::from(Span::styled("─".repeat(width as usize), Style::default().fg(Color::DarkGray)))`, then push empty Line
|
||||
|
||||
**Images (REND-07):**
|
||||
- `Start(Tag::Image { .. })`: set `in_image = true`, clear `image_alt`
|
||||
- Collect text inside image into `image_alt`
|
||||
- `End(TagEnd::Image)`: push `Span::styled(format!("[IMAGE: {}]", image_alt), Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM))` to current_spans, set `in_image = false`
|
||||
|
||||
**Links:**
|
||||
- `Start(Tag::Link { .. })`: for Phase 2, just pass through — link text renders as normal styled text. Links become interactive in Phase 3.
|
||||
- `End(TagEnd::Link)`: nothing special
|
||||
|
||||
**All other events:** ignore (`_ => {}`)
|
||||
|
||||
Do NOT add table or code block rendering yet — Task 2 handles those. For now, stub them:
|
||||
- `Start(Tag::CodeBlock(_))`: set `in_code_block = true`, clear `code_buf`, capture lang
|
||||
- `End(TagEnd::CodeBlock)`: call a placeholder `emit_code_block()` that pushes `code_buf` lines as plain styled text
|
||||
- Table events: set `in_table = true`, accumulate cells, call placeholder `emit_table()` on End
|
||||
|
||||
**Helper methods on RenderState:**
|
||||
- `flush_line()`: take current_spans, make a Line, apply blockquote prefix if needed, push to lines, clear current_spans
|
||||
- `current_style()`: return top of style_stack or Style::default()
|
||||
- `finish() -> Vec<Line<'static>>`: return the accumulated lines
|
||||
|
||||
Ensure ALL Span content uses `.to_string()` for owned strings — every output must be `Line<'static>`.
|
||||
</action>
|
||||
<verify>`cargo check` passes after temporarily adding `mod renderer;` to main.rs. Test with a simple markdown string mentally: `# Hello\n\nSome **bold** text.` should produce heading line + decorator + empty + paragraph line + empty.</verify>
|
||||
<done>renderer.rs exists with render_markdown() function that handles headings (H1-H6 with colors + decorators), paragraphs, bold/italic/inline code, lists (ordered/unordered/nested), blockquotes with │ prefix, horizontal rules, images as placeholders, soft/hard breaks. Code blocks and tables have working stubs that render content without fancy borders.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add code block borders with syntax highlighting and GFM table grid rendering</name>
|
||||
<files>src/renderer.rs</files>
|
||||
<action>
|
||||
Replace the placeholder `emit_code_block()` and `emit_table()` with full implementations.
|
||||
|
||||
**Code blocks (REND-03, REND-09):**
|
||||
|
||||
Replace the code block stub with `emit_code_block(code_buf: &str, lang: &str, width: u16, lines: &mut Vec<Line<'static>>)`:
|
||||
|
||||
1. Get syntax-highlighted lines by calling `crate::highlighter::highlight_code(code_buf, lang)`
|
||||
2. Determine the box width: `max(highlighted lines' max display width + 4, lang.len() + 6)`, capped at `width`
|
||||
3. Build the top border line:
|
||||
- `╭─ {lang} ` + `─` repeated to fill + `╮`
|
||||
- Style: `Color::DarkGray` for border chars, `Color::Yellow` for lang label
|
||||
- If lang is empty, just `╭` + `─` fill + `╮`
|
||||
4. For each highlighted line: `│ ` + highlighted spans + padding to box width + ` │`
|
||||
- Border chars (`│`) in `Color::DarkGray`
|
||||
- Content spans keep their syntax highlighting colors from highlight_code()
|
||||
5. Build the bottom border: `╰` + `─` fill + `╯` in `Color::DarkGray`
|
||||
6. Push empty Line before and after the code block for spacing
|
||||
|
||||
If `highlight_code()` returns empty (no syntax found), fall back to plain `Color::Gray` text for the code content.
|
||||
|
||||
**Tables (REND-08, REND-09):**
|
||||
|
||||
Replace the table stub with `emit_table(alignments: &[Alignment], rows: &[Vec<String>], lines: &mut Vec<Line<'static>>)`:
|
||||
|
||||
1. Compute column widths: for each column index, find `max(cell.len() + 2)` across all rows (header + data), minimum 5 chars
|
||||
2. Build helper `build_border_line(left: char, mid: char, right: char, fill: char, widths: &[usize]) -> String`:
|
||||
- e.g., `build_border_line('┌', '┬', '┐', '─', &widths)` → `┌──────┬──────┐`
|
||||
3. Emit top border: `build_border_line('┌', '┬', '┐', '─', ...)` styled `Color::Cyan`
|
||||
4. Emit header row (rows[0]):
|
||||
- For each cell: `│` + padded cell text (aligned per alignments[i]) + space
|
||||
- Cell text styled `Color::LightCyan + Modifier::BOLD`
|
||||
- `│` border chars styled `Color::Cyan`
|
||||
- Trailing `│`
|
||||
5. Emit header separator: `build_border_line('├', '┼', '┤', '─', ...)` styled `Color::Cyan`
|
||||
6. Emit data rows (rows[1..]):
|
||||
- Same format as header but with `Style::default()` for cell text
|
||||
7. Emit bottom border: `build_border_line('└', '┴', '┘', '─', ...)` styled `Color::Cyan`
|
||||
8. Push empty Line after table for spacing
|
||||
|
||||
**Alignment handling in table cells:**
|
||||
- `Alignment::Left` or `Alignment::None`: left-pad with 1 space, right-pad to width
|
||||
- `Alignment::Right`: left-pad to width, right-pad with 1 space
|
||||
- `Alignment::Center`: center the text within width
|
||||
|
||||
Ensure the table state machine in the event handler correctly:
|
||||
- Captures `alignments` from `Start(Tag::Table(alignments))`
|
||||
- Pushes new `Vec<String>` on `Start(Tag::TableRow)` AND `Start(Tag::TableHead)`
|
||||
- Accumulates cell text on `Event::Text` when `in_table` is true
|
||||
- Pushes cell to current row on `End(TagEnd::TableCell)`
|
||||
- Calls `emit_table()` on `End(TagEnd::Table)`
|
||||
</action>
|
||||
<verify>`cargo check` passes. Mentally verify: a markdown code block ````rust\nlet x = 1;\n```` should produce 4 lines (top border, content, bottom border) plus spacing. A 2-column 3-row table should produce 6 lines (top border, header, separator, 2 data rows, bottom border) plus spacing.</verify>
|
||||
<done>Code blocks render with ╭─╮│╰─╯ rounded borders, language label in yellow, syntax-highlighted content from highlighter.rs. GFM tables render with ┌┬┐├┼┤└┴┘ full box-drawing grid, bold LightCyan header row, aligned cell content. Both emit proper spacing.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `cargo check` passes with renderer.rs compiling
|
||||
2. `render_markdown("# Test\n\nHello **world**\n", 80)` would produce: heading line (LightCyan bold), ══ decorator, empty, paragraph with bold span, empty
|
||||
3. All pulldown-cmark Event variants for Phase 2 requirements are handled (Heading, Paragraph, Strong, Emphasis, Code, CodeBlock, List, Item, BlockQuote, Rule, Image, Table, TableHead, TableRow, TableCell, Text, SoftBreak, HardBreak)
|
||||
4. Code block output includes ╭╮│╰╯ border characters with syntax-highlighted inner content
|
||||
5. Table output includes ┌┬┐├┼┤└┴┘ border characters with bold header row
|
||||
6. Every Span uses owned String content (.to_string()) — no borrowed lifetimes
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- renderer.rs contains a single public function `render_markdown()` that processes all markdown constructs
|
||||
- Headings H1-H6 have distinct CGA colors and H1/H2 have decorators
|
||||
- Bold, italic, inline code have correct terminal modifiers/colors
|
||||
- Lists support ordered/unordered with nesting
|
||||
- Code blocks have syntax highlighting with CGA colors and rounded box-drawing borders
|
||||
- Tables have full box-drawing grid with aligned columns
|
||||
- All output is Vec<Line<'static>> with no lifetime dependencies
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-vault-core-and-rendering/02-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,318 @@
|
||||
---
|
||||
phase: 02-vault-core-and-rendering
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "02-01"
|
||||
- "02-02"
|
||||
files_modified:
|
||||
- src/app.rs
|
||||
- src/main.rs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- NAV-05
|
||||
- NAV-06
|
||||
- NAV-07
|
||||
- NAV-08
|
||||
- NAV-09
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "App starts and displays index.md content from the configured vault_path"
|
||||
- "When index.md is missing, user sees a BBS-style error screen with box-drawing border and helpful message"
|
||||
- "User can scroll content with j/k (one line), arrow keys (one line), PgUp/PgDn (full page)"
|
||||
- "Status bar at the bottom shows filename on the left and keyboard hints on the right in reverse video"
|
||||
- "Terminal resize reflows the layout without crashing or corrupting display"
|
||||
- "Quit behavior from Phase 1 (q key, double Ctrl+C) is preserved"
|
||||
artifacts:
|
||||
- path: "src/app.rs"
|
||||
provides: "Document display, scrolling, status bar, error screen"
|
||||
contains: "DocumentState"
|
||||
- path: "src/main.rs"
|
||||
provides: "Wiring: mod declarations, highlighter init, vault loading"
|
||||
contains: "mod renderer"
|
||||
key_links:
|
||||
- from: "src/main.rs"
|
||||
to: "src/highlighter.rs"
|
||||
via: "init_highlighter() called before App::new()"
|
||||
pattern: "init_highlighter"
|
||||
- from: "src/app.rs"
|
||||
to: "src/vault.rs"
|
||||
via: "load_document() called to get markdown content"
|
||||
pattern: "load_document"
|
||||
- from: "src/app.rs"
|
||||
to: "src/renderer.rs"
|
||||
via: "render_markdown() converts content to styled lines for display"
|
||||
pattern: "render_markdown"
|
||||
- from: "src/app.rs"
|
||||
to: "ratatui::widgets::Paragraph"
|
||||
via: "Paragraph::new(lines).scroll((offset, 0)) for scrollable content"
|
||||
pattern: "scroll"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire everything together: rework app.rs to display rendered markdown content with scrolling, a status bar, and error screens; update main.rs to register all new modules, initialize the highlighter, and load index.md on startup.
|
||||
|
||||
Purpose: This is the integration plan that turns the separate modules (vault, renderer, highlighter) into a working content viewer.
|
||||
Output: Running `cargo run` displays index.md from the vault with full markdown styling, scrolling, and a status bar.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-vault-core-and-rendering/02-RESEARCH.md
|
||||
@.planning/phases/02-vault-core-and-rendering/02-CONTEXT.md
|
||||
@.planning/phases/02-vault-core-and-rendering/02-01-SUMMARY.md
|
||||
@.planning/phases/02-vault-core-and-rendering/02-02-SUMMARY.md
|
||||
@src/app.rs
|
||||
@src/main.rs
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Rework app.rs with document state, scrolling, status bar, and error screen</name>
|
||||
<files>src/app.rs</files>
|
||||
<action>
|
||||
**Extend the App struct** — add new fields while preserving ALL Phase 1 fields and behavior:
|
||||
|
||||
```rust
|
||||
pub struct App {
|
||||
// Phase 1 fields (keep exactly as-is)
|
||||
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,
|
||||
}
|
||||
```
|
||||
|
||||
**Add `DocumentState` enum:**
|
||||
|
||||
```rust
|
||||
pub enum DocumentState {
|
||||
Loaded {
|
||||
filename: String,
|
||||
lines: Vec<Line<'static>>,
|
||||
},
|
||||
Missing {
|
||||
path: PathBuf,
|
||||
},
|
||||
Error {
|
||||
path: PathBuf,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Update `App::new()`** to accept the initial document state:
|
||||
|
||||
Change signature to `pub fn new(is_login_shell: bool, config: Config, document: DocumentState) -> Self` with `scroll_offset: 0`.
|
||||
|
||||
**Rework `draw()`** — replace the Phase 1 placeholder UI entirely:
|
||||
|
||||
1. Split frame into two areas using Layout:
|
||||
```rust
|
||||
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];
|
||||
```
|
||||
|
||||
2. **Content area rendering** — match on `self.document`:
|
||||
- `DocumentState::Loaded { filename, lines }`:
|
||||
- Create `Paragraph::new(lines.clone()).scroll((self.scroll_offset, 0))`
|
||||
- Do NOT enable Wrap — lines are pre-rendered
|
||||
- Render into `content_area`
|
||||
- `DocumentState::Missing { path }`:
|
||||
- Call `draw_error_screen(frame, content_area, path)` (see below)
|
||||
- `DocumentState::Error { path, reason }`:
|
||||
- Call `draw_error_screen_with_reason(frame, content_area, path, reason)`
|
||||
|
||||
3. **Status bar rendering (NAV-08):**
|
||||
- Determine filename text: from DocumentState::Loaded filename, or "ERROR" for missing/error states
|
||||
- Determine hints: `"q:Quit j/k:Scroll PgUp/PgDn:Page"` (if not login shell), or `"Ctrl+C×2:Quit j/k:Scroll PgUp/PgDn:Page"` (if login shell)
|
||||
- Build the status bar as a single Line with the filename left-aligned and hints right-aligned:
|
||||
- Calculate padding to fill the full status_area.width between filename and hints
|
||||
- Style: `Style::default().add_modifier(Modifier::REVERSED)` on the entire Paragraph
|
||||
- Render `Paragraph::new(status_line).style(Style::default().add_modifier(Modifier::REVERSED))` into `status_area`
|
||||
|
||||
4. **Quit prompt overlay** — if `self.show_quit_prompt` is true, render the "Press Ctrl+C again..." message. Options:
|
||||
- Render it as part of the status bar (replace hints with the warning), OR
|
||||
- Overlay it on the bottom of content_area
|
||||
- Recommended: replace the status bar content with the quit prompt in yellow bold reverse video
|
||||
|
||||
**Error screen widget (NAV-07):**
|
||||
|
||||
Create a private method `draw_error_screen(frame: &mut Frame, area: Rect, path: &Path)`:
|
||||
|
||||
Use ratatui's `Block` widget with `BorderType::Plain` (┌─┐│└─┘) borders:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ *** SYSTEM ERROR *** │
|
||||
│ │
|
||||
│ No index.md found in vault: │
|
||||
│ /path/to/vault │
|
||||
│ │
|
||||
│ Create index.md to begin. │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Block border: `Color::Red`
|
||||
- "SYSTEM ERROR" text: `Color::LightRed + Modifier::BOLD`
|
||||
- Path: `Color::Yellow`
|
||||
- Hint: `Color::DarkGray`
|
||||
- Center the block in the content area (calculate a centered Rect)
|
||||
|
||||
Also create `draw_error_screen_with_reason()` for ReadError states — same layout but shows the error reason instead of "No index.md found".
|
||||
|
||||
**Extend `handle_key()` for scrolling (NAV-05):**
|
||||
|
||||
Add new key bindings BEFORE the existing `_` catch-all:
|
||||
|
||||
```rust
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
self.scroll_down(1);
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
self.scroll_up(1);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
self.scroll_down(self.page_height());
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
self.scroll_up(self.page_height());
|
||||
}
|
||||
```
|
||||
|
||||
Helper methods:
|
||||
- `scroll_down(n: u16)`: `self.scroll_offset = self.scroll_offset.saturating_add(n).min(self.max_scroll())`
|
||||
- `scroll_up(n: u16)`: `self.scroll_offset = self.scroll_offset.saturating_sub(n)`
|
||||
- `max_scroll() -> u16`: if Loaded, `lines.len() as u16 - page_height`, else 0. Use `saturating_sub`.
|
||||
- `page_height() -> u16`: store the content area height from the last draw, or default to 24. Add a `last_content_height: u16` field to App, update it in draw().
|
||||
|
||||
**IMPORTANT:** The scroll keys (j/k/arrows/PgUp/PgDn) must NOT trigger the quit prompt dismissal. Currently the `_ =>` branch dismisses the prompt. The scroll keys should be handled before the `_` catch-all, and only dismiss the prompt for truly unrelated keys.
|
||||
|
||||
**Resize handling (NAV-09):**
|
||||
- In `run_event_loop()`, add `Event::Resize(_, _) => {}` handling — ratatui handles the buffer resize automatically for `Viewport::Fullscreen`; we just need to ensure the event is consumed so it doesn't fall through
|
||||
- `max_scroll()` recomputes on every draw based on `last_content_height`, so resize is handled naturally
|
||||
|
||||
**Preserve ALL Phase 1 behavior:**
|
||||
- Double Ctrl+C quit mechanism
|
||||
- Login shell mode suppressing 'q' key
|
||||
- `show_goodbye()` function unchanged
|
||||
- `ShutdownReason` enum unchanged
|
||||
- `DOUBLE_PRESS_WINDOW` unchanged
|
||||
</action>
|
||||
<verify>`cargo check` passes. Mentally trace: App starts with DocumentState::Loaded, draw() shows content with status bar, j/k adjust scroll_offset, q still quits, Ctrl+C double-press still works.</verify>
|
||||
<done>app.rs has DocumentState enum, scroll_offset field, status bar with filename+hints in reverse video, BBS error screen for missing files, j/k/arrow/PgUp/PgDn scroll keys, resize handling. All Phase 1 quit behavior preserved.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire main.rs with module declarations, highlighter init, and index.md loading</name>
|
||||
<files>src/main.rs</files>
|
||||
<action>
|
||||
**Add module declarations** at the top of main.rs (add to the existing mod block):
|
||||
|
||||
```rust
|
||||
mod app;
|
||||
mod config;
|
||||
mod highlighter;
|
||||
mod renderer;
|
||||
mod signals;
|
||||
mod terminal;
|
||||
mod vault;
|
||||
```
|
||||
|
||||
**Update the startup sequence** in `fn main()`:
|
||||
|
||||
After step 3 (config loading) and before step 4 (panic hook), add:
|
||||
|
||||
```rust
|
||||
// 3a. Initialize syntax highlighting (one-time, ~23ms)
|
||||
highlighter::init_highlighter();
|
||||
|
||||
// 3b. Load initial document (index.md from vault)
|
||||
let initial_doc = match vault::load_document(&app_config.vault_path, "index.md") {
|
||||
vault::VaultDocument::Loaded { path, content } => {
|
||||
// Get terminal width for rendering — use a reasonable default before terminal init
|
||||
// We'll re-render if needed, but 80 is safe for initial parse
|
||||
let width = ratatui::crossterm::terminal::size()
|
||||
.map(|(w, _)| w)
|
||||
.unwrap_or(80);
|
||||
let filename = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "index.md".to_string());
|
||||
let lines = renderer::render_markdown(&content, width);
|
||||
app::DocumentState::Loaded { filename, lines }
|
||||
}
|
||||
vault::VaultDocument::Missing { path } => {
|
||||
app::DocumentState::Missing { path }
|
||||
}
|
||||
vault::VaultDocument::ReadError { path, reason } => {
|
||||
app::DocumentState::Error { path, reason }
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Update App::new() call** in step 7:
|
||||
|
||||
```rust
|
||||
let mut app_state = app::App::new(is_login_shell, app_config, initial_doc);
|
||||
```
|
||||
|
||||
**Event loop resize re-rendering consideration:**
|
||||
The initial render uses terminal width at startup. On resize, the content would ideally re-render. For Phase 2, accept the initial render width — re-rendering on resize can be added later if needed. The content still displays correctly; only horizontal rules and code block borders may not be pixel-perfect after resize.
|
||||
|
||||
**Remove the `#[allow(dead_code)]` on config field** in app.rs if it's now used (vault_path is accessed). Actually, config is passed at construction but vault loading happens in main.rs, so config may still appear unused inside App. Keep the allow if needed, or add a method to access vault_path for future use.
|
||||
</action>
|
||||
<verify>
|
||||
1. `cargo build` succeeds
|
||||
2. Create a test vault: `mkdir -p /tmp/bbs-test-vault && echo '# Welcome\n\nHello **world**!\n\n- Item one\n- Item two\n\n> A blockquote\n\n---\n\n```rust\nlet x = 42;\n```' > /tmp/bbs-test-vault/index.md`
|
||||
3. Run: `cargo run -- --config /dev/null` with a bbs.toml pointing to the test vault (or modify the default path for testing)
|
||||
4. Verify: content displays with colored headings, styled text, and a status bar at the bottom
|
||||
5. Verify: j/k scroll content, q exits cleanly
|
||||
6. Verify: without index.md, the BBS error screen appears
|
||||
</verify>
|
||||
<done>main.rs declares all 7 modules (app, config, highlighter, renderer, signals, terminal, vault). Startup sequence initializes highlighter, loads index.md via vault, renders via renderer, passes DocumentState to App. Running `cargo run` displays styled markdown content with scrolling and status bar. Missing index.md shows error screen.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `cargo build` succeeds with zero warnings on new code
|
||||
2. With a vault containing index.md: app displays rendered markdown content
|
||||
3. Without index.md in vault: app displays BBS error screen with box-drawing border
|
||||
4. j/k keys scroll one line, PgUp/PgDn scroll one page
|
||||
5. Status bar shows filename on left, keyboard hints on right, in reverse video
|
||||
6. q key exits (non-login-shell), double Ctrl+C exits (always)
|
||||
7. Terminal resize does not crash — content area adjusts
|
||||
8. All Phase 1 safety features (panic hook, signal handling, terminal restore) still work
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Running `cargo run` displays index.md from the configured vault with full markdown styling
|
||||
- Missing index.md shows BBS error screen instead of crashing
|
||||
- Content scrolls smoothly with j/k/arrows/PgUp/PgDn
|
||||
- Status bar visible at bottom with filename and keyboard hints
|
||||
- All Phase 1 quit/safety behavior preserved
|
||||
- Terminal resize handled gracefully
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-vault-core-and-rendering/02-03-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user