319 lines
13 KiB
Markdown
319 lines
13 KiB
Markdown
---
|
||
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>
|