Yet Another Markdown Editor! TUI for READMEs and friends.
editor markdown readme development-tool
yame _docs PLAN.md
42 kB
Markdown
at dev

PLAN.md — yame v1 Implementation Plan #

Overview #

This plan covers the full v1 build sequence for yame. Each phase ends with something runnable or meaningfully testable. Dependencies between phases are explicit. Roadmap seams (v1.5/v2/v3) are marked where they occur.

No Rust source exists yet. The project root has only docs, a README stub, and .gitignore. Every step below starts from scratch.


Phase 0 — Project Scaffold #

Goal: A compiling, running Rust binary that opens, prints a placeholder frame, and exits cleanly.

Step 0.1 — cargo init #

Run cargo init --name yame in the project root. This creates src/main.rs and Cargo.toml.

Acceptance: cargo run prints "Hello, world!" and exits with code 0.

Step 0.2 — Add all v1 dependencies to Cargo.toml #

[dependencies]
ratatui = "0.29"
tui-textarea = { version = "0.7", features = ["crossterm"] }
crossterm = "0.28"
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
arboard = "3"

[profile.release]
strip = true
opt-level = 3

Pin minor versions. Do not use *.

Acceptance: cargo build succeeds with no errors. Warnings about unused deps are acceptable at this stage.

Step 0.3 — Module skeleton #

Create the source module tree:

src/
  main.rs          — arg parsing, terminal setup/teardown, top-level event loop
  app.rs           — App struct (all mutable state)
  config.rs        — Config/Theme loading, blend(), derived tokens
  decoration.rs    — build_decoration_map() and DecorationMap type
  renderer.rs      — custom Ratatui widget
  layout.rs        — column width computation, area splitting
  clipboard.rs     — arboard wrapper with error handling
  status.rs        — StatusMessage (timed messages, exit prompt state)

Declare all modules in main.rs with mod foo;. Each file can be empty // stub for now.

Acceptance: cargo build succeeds. No logic yet.

Step 0.4 — CI pipeline #

Create .github/workflows/ci.yml (already written at project root). It runs two jobs on every push and PR:

  • test: cargo test --all
  • lint: cargo fmt --check + cargo clippy -- -D warnings

Push the scaffold commit and confirm both jobs go green on GitHub Actions before writing any further code. A green baseline now means any future red is a real regression, not noise.

Acceptance: Both CI jobs pass on the empty scaffold. The badge is green.


Phase 1 — Terminal Lifecycle #

Goal: A black terminal screen that responds to Ctrl+C/q and restores the terminal on exit. This is the foundation everything else sits on.

Step 1.1 — Terminal setup and teardown in main.rs #

Implement the standard Ratatui crossterm setup:

  1. enable_raw_mode()
  2. execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
  3. Create Terminal::new(CrosstermBackend::new(stdout))
  4. On exit (normal or panic): disable_raw_mode(), execute!(stdout, LeaveAlternateScreen, DisableMouseCapture)

Use a setup_panic_hook() function that runs teardown inside std::panic::set_hook. This is critical — a panicking process that skips DisableMouseCapture leaves the terminal broken for the user.

Warning: DisableMouseCapture must be called on every exit path including panics. Test this by inserting a deliberate panic!() and verifying the terminal is clean afterward.

Step 1.2 — Minimal event loop in main.rs #

loop {
    terminal.draw(|f| { /* blank frame */ })?;
    if event::poll(Duration::from_millis(16))? {
        match event::read()? {
            Event::Key(k) if k.code == KeyCode::Char('q') => break,
            _ => {}
        }
    }
}

Acceptance: cargo run -- somefile.md shows a blank terminal, q exits cleanly, terminal is restored.

Step 1.3 — CLI argument parsing in main.rs #

Parse std::env::args() manually (no clap needed for a single positional arg):

  • 0 args: print "Usage: yame <file>" to stderr, exit with code 1
  • 1 arg: store the path; if the file does not exist that is fine (new buffer)
  • 2+ args: same usage error

Acceptance: yame with no args prints usage and exits 1. yame somefile.md opens (blank screen) and exits on q.


Phase 2 — Config & Theming #

Goal: Config is loaded at startup (with defaults when absent), and the Theme struct with all derived tokens is available throughout the app.

Step 2.1 — Config and Theme structs in config.rs #

Define structs with #[derive(Deserialize, Default)]:

pub struct Palette { pub text, accent, muted, code, bg, warning: String }
pub struct ThemeOverrides { /* all override fields as Option<String> */ }
pub struct HeadingColors { pub h1, h2, h3, h4, h5, h6: Option<String> }
pub struct Config { pub theme: PaletteConfig, pub layout: LayoutConfig }

Separate the raw TOML-deserialized Config from the computed Theme (resolved Color values). Theme is never serialized.

Step 2.2 — blend() utility #

Write tests first. Add a #[cfg(test)] block to config.rs with failing tests before writing the function body:

#[cfg(test)]
mod tests {
    use super::*;
    #[test] fn blend_midpoint() { assert_eq!(blend((255,0,0),(0,0,0),0.5), (127,0,0)); }
    #[test] fn blend_zero_is_bg() { assert_eq!(blend((255,0,0),(10,20,30),0.0), (10,20,30)); }
    #[test] fn blend_one_is_fg() { assert_eq!(blend((255,0,0),(10,20,30),1.0), (255,0,0)); }
}

Run cargo test — all three should fail with "unresolved function". Implement blend() exactly as specified in the design doc until they pass.

Acceptance: All three tests pass. cargo clippy is clean.

Step 2.3 — Color parsing #

Write tests first.

#[test] fn parse_valid_color() { assert_eq!(parse_hex_color("#cba6f7"), Ok((203,166,247))); }
#[test] fn parse_missing_hash() { assert!(parse_hex_color("cba6f7").is_err()); }
#[test] fn parse_too_short() { assert!(parse_hex_color("#cba6f").is_err()); }
#[test] fn parse_non_hex() { assert!(parse_hex_color("#zzzzzz").is_err()); }

Run cargo test — all four fail. Implement parse_hex_color(s: &str) -> Result<(u8,u8,u8), String>. No external color-parsing crate needed — it is 6 hex digits. Convert the tuple to ratatui::style::Color::Rgb(r, g, b) at the call site.

Step 2.4 — Derived token computation #

Write tests first. Using the Catppuccin Mocha default palette (hardcoded in the test), assert that a selection of derived tokens compute to their expected values:

#[test]
fn derived_heading_bg() {
    let theme = Theme::from_palette(&Palette::default(), &ThemeOverrides::default(), &HeadingColors::default());
    // heading_bg = blend(accent #cba6f7, bg #1e1e2e, 0.15)
    // Expected: blend((203,166,247), (30,30,46), 0.15) ≈ (55,49,72) as Rgb
    assert!(matches!(theme.heading_bg, Color::Rgb(r, g, b) if r == 55 && g == 49 && b == 72));
}
#[test]
fn override_takes_precedence() {
    let mut overrides = ThemeOverrides::default();
    overrides.bold_color = Some("#ff0000".into());
    let theme = Theme::from_palette(&Palette::default(), &overrides, &HeadingColors::default());
    assert_eq!(theme.bold_color, Color::Rgb(255, 0, 0));
}

Run cargo test — fail. Implement Theme::from_palette. For each derived token: check override first, fall back to blend formula. Add selection_bg and selection_fg: derive as blend(accent, bg, 0.6) and bg respectively.

Step 2.5 — Config file loading #

XDG path resolution:

fn config_path() -> PathBuf {
    let base = std::env::var("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            std::env::var("HOME").map(PathBuf::from)
                .unwrap_or_default()
                .join(".config")
        });
    base.join("yame").join("config.toml")
}

Load sequence:

  1. If path does not exist → return Config::default() (Catppuccin Mocha)
  2. If file exists but TOML parse fails → eprintln! warning, return Config::default()
  3. If individual color fields are invalid → skip them (use field default), queue a StatusMessage warning for display

Write tests first for the XDG path logic (no filesystem needed — just env var manipulation):

#[test]
fn xdg_config_home_used_when_set() {
    std::env::set_var("XDG_CONFIG_HOME", "/tmp/custom");
    assert_eq!(config_path(), PathBuf::from("/tmp/custom/yame/config.toml"));
    std::env::remove_var("XDG_CONFIG_HOME");
}

Warning: Config errors must not crash the editor. The user must be able to continue editing.

Acceptance: All config tests pass. Manually verify: place an invalid TOML file at the config path, run yame, confirm it opens with defaults and no crash.

Step 2.6 — Italic support detection #

In main.rs or a terminal.rs helper:

fn supports_italic() -> bool {
    let term = std::env::var("TERM").unwrap_or_default();
    matches!(term.as_str(),
        "xterm-256color" | "tmux-256color" | "screen-256color" |
        "kitty" | "alacritty" | "rio" | "wezterm" | "foot"
    ) || term.starts_with("xterm-kitty")
}

Store the result in App. When false, the renderer never applies Modifier::ITALIC — it applies the italic_color only. When false also queue the dismissible italic warning message.

Acceptance: Compiles; test with TERM=dumb cargo run -- file.md and verify the flag is false.


Phase 3 — App State & tui-textarea Integration #

Goal: A TextArea is initialized with file content, and the event loop routes keyboard and mouse events into it. The buffer is editable.

Step 3.1 — App struct in app.rs #

pub struct App {
    pub textarea: TextArea<'static>,
    pub file_path: PathBuf,
    pub is_dirty: bool,
    pub saved_content: Option<Vec<String>>,  // for dirty-flag comparison after undo
    pub theme: Theme,
    pub italic_support: bool,
    pub last_keystroke: Option<Instant>,
    pub decoration_map: DecorationMap,
    pub word_count: usize,
    pub status: StatusLine,
    pub config_warnings: Vec<String>,
}

Step 3.2 — File loading into TextArea #

fn load_file(path: &Path) -> io::Result<TextArea<'static>> {
    if path.exists() {
        let content = fs::read_to_string(path)?;
        let lines: Vec<String> = content.lines().map(String::from).collect();
        Ok(TextArea::new(lines))
    } else {
        Ok(TextArea::default())  // new empty buffer
    }
}

Note: TextArea::new() accepts Vec<String>. Do not call textarea.widget() anywhere — it is never used for display.

Step 3.3 — Event routing #

In the event loop:

match event::read()? {
    Event::Key(key) => {
        // intercept app-level keys before passing to textarea
        match (key.modifiers, key.code) {
            (CONTROL, KeyCode::Char('s')) => handle_save(&mut app)?,
            (CONTROL, KeyCode::Char('x')) => handle_exit(&mut app)?,
            (CONTROL, KeyCode::Char('c')) => handle_copy(&mut app),
            (CONTROL, KeyCode::Char('v')) => handle_paste(&mut app),
            _ => {
                app.textarea.input(key);
                app.is_dirty = true;
                app.last_keystroke = Some(Instant::now());
            }
        }
    }
    Event::Mouse(mouse) => { app.textarea.input(Event::Mouse(mouse)); }
    Event::Resize(_, _) => { /* next draw() picks up new dimensions automatically */ }
    _ => {}
}

Warning: Do not pass Ctrl+S, Ctrl+X, Ctrl+C, Ctrl+V to textarea.input(). tui-textarea will consume them as internal keymap events.

Acceptance: Launch with a real file. Type text, see cursor move (even with a placeholder renderer). The buffer is live.

Step 3.4 — Dirty flag and undo/redo #

is_dirty is set true on any textarea mutation. It is cleared after save. Ctrl+Z / Ctrl+Y pass directly to textarea.input() as-is (tui-textarea handles undo/redo internally). After undo/redo, recompute is_dirty by comparing textarea.lines() to saved_content.


Phase 4 — Layout Engine #

Goal: The centered editing column is computed correctly and all layout rectangles are available to the renderer.

Step 4.1 — Column width computation in layout.rs #

pub struct EditorLayout {
    pub full: Rect,
    pub column: Rect,
    pub scrollbar: Rect,   // 1 column wide, right of editing column
    pub info_line: Rect,   // second-to-last row
    pub status_bar: Rect,  // last row
}

pub fn compute_layout(area: Rect, min_cols: u16) -> EditorLayout {
    let col_width = (area.width / 2).max(min_cols).min(area.width);
    let margin = (area.width.saturating_sub(col_width)) / 2;
    // editing area: area.height - 2 rows (info_line + status_bar)
    // column starts at x = margin, scrollbar at x = margin + col_width
    // ...
}

The scrollbar occupies the 1 column immediately to the right of the editing column (within the right margin).

Write tests first.

#[test]
fn layout_wide_terminal_has_margins() {
    let area = Rect::new(0, 0, 200, 50);
    let layout = compute_layout(area, 60);
    assert!(layout.column.x > 0, "expected left margin");
    assert_eq!(layout.column.width, 100); // 50% of 200
}
#[test]
fn layout_narrow_terminal_fills_width() {
    let area = Rect::new(0, 0, 40, 50);
    let layout = compute_layout(area, 60); // min_cols > width
    assert_eq!(layout.column.x, 0);
    assert_eq!(layout.column.width, 40);
}
#[test]
fn layout_always_has_status_and_info_rows() {
    let area = Rect::new(0, 0, 80, 24);
    let layout = compute_layout(area, 40);
    assert_eq!(layout.status_bar.y, 23);
    assert_eq!(layout.info_line.y, 22);
}

Run cargo test — fail. Implement compute_layout. Acceptance: all tests pass.

Step 4.2 — Scroll offset tracking #

Maintain scroll_top: usize in App. Update after every event that may move the cursor:

let (cursor_row, _) = app.textarea.cursor();
let visible_rows = layout.column.height as usize;
if cursor_row < app.scroll_top {
    app.scroll_top = cursor_row;
}
if cursor_row >= app.scroll_top + visible_rows {
    app.scroll_top = cursor_row - visible_rows + 1;
}

visible_rows comes from EditorLayout::column.height. Compute layout before this check each frame.


Phase 5 — Status Bar & Info Line #

Goal: Status bar and info line render with real data. These use simple Ratatui widgets and can be built before the main editor renderer.

Step 5.1 — StatusLine state in status.rs #

Write tests first for the timed-message and dismiss logic — these are pure state machines with no terminal dependency:

#[test]
fn timed_message_expires() {
    let mut s = StatusLine::default();
    s.set_timed("Saved.", Duration::from_millis(1));
    std::thread::sleep(Duration::from_millis(5));
    s.tick();
    assert!(matches!(s.mode, StatusMode::Normal));
}
#[test]
fn dismissible_clears_on_dismiss() {
    let mut s = StatusLine::default();
    s.set_dismissible("warning");
    s.dismiss();
    assert!(matches!(s.mode, StatusMode::Normal));
}

Run cargo test — fail. Implement the struct and methods until they pass.

pub enum StatusMode {
    Normal,
    TimedMessage { text: String, expires_at: Instant },
    DismissibleMessage(String),
    ExitPrompt,
}

pub struct StatusLine {
    pub mode: StatusMode,
}

impl StatusLine {
    pub fn set_timed(&mut self, text: impl Into<String>, duration: Duration) { ... }
    pub fn set_dismissible(&mut self, text: impl Into<String>) { ... }
    pub fn tick(&mut self) { /* clear TimedMessage if expired */ }
    pub fn dismiss(&mut self) { /* clear DismissibleMessage */ }
}

Step 5.2 — Status bar widget #

Implement as fn render_status_bar(f: &mut Frame, area: Rect, app: &App).

Left segment: filename. Take file_path.components(), join the last 3 (up to 2 parent dirs + filename). Dirty flag: append [*] when app.is_dirty (use [*] for v1; Nerd Fonts icon can be added as a config option later).

Center segment: ^S Save ^X Exit ^Z Undo ^Y Redo

Right segment: empty in v1.

Powerline separators: \u{e0b0} (right-pointing filled arrow) between segments, with color transitions between ui_bar and segment backgrounds.

In ExitPrompt mode: render full width as Save modified buffer? [Y/N/Cancel] in warning color.

In TimedMessage/DismissibleMessage mode: render the message text across the center of the bar.

Acceptance: Status bar renders with filename and hints. Dirty flag appears when buffer is modified.

Step 5.3 — Floating info line #

Render at EditorLayout::info_line, left-aligned, with ui_bg background:

Ln 42, Col 8 · 1,204 words

Cursor position from textarea.cursor() (0-indexed; display as 1-indexed). Word count from app.word_count. Thousands separator: manual helper — no locale crate needed.

Acceptance: Compiles and renders. Word count shows 0 until Phase 6 wires it in.

Step 5.4 — Scrollbar widget #

Use ratatui::widgets::Scrollbar in vertical orientation in EditorLayout::scrollbar. Parameters: content_length = total_lines, position = scroll_top. Style: track as ui_bar, thumb as accent.


Phase 6 — Decoration Engine #

Goal: build_decoration_map() is implemented and produces correct styled spans for all v1 Markdown elements.

Testing discipline for this entire phase: build_decoration_map is a pure function (text in, map out) with no terminal or UI dependency. Write a failing test before implementing each element sub-type in Step 6.4. Use tests/fixtures/sample.md as the reference input — it contains every element type. The pattern for each:

// 1. Write the test (it fails — the element isn't handled yet)
// 2. cargo test → red
// 3. Implement the element handler
// 4. cargo test → green
// 5. Move to next element

Step 6.1 — Types in decoration.rs #

pub type DecorationMap = HashMap<usize, Vec<StyledSpan>>;

pub struct StyledSpan {
    pub char_start: usize,   // char index within the line (not byte index)
    pub char_end: usize,
    pub style: Style,
    pub is_blockquote: bool, // flag for renderer to handle continuation indent
    pub full_line_bg: Option<Color>, // for heading_bg — renderer expands to column width
}

Step 6.2 — Byte-to-line/char mapping #

Before iterating events, build a lookup structure from the full buffer text:

// Returns (line_index, char_offset_within_line) for a given byte offset
fn byte_to_line_char(line_starts: &[usize], text: &str, byte: usize) -> (usize, usize) {
    let line = line_starts.partition_point(|&s| s <= byte).saturating_sub(1);
    let line_start_byte = line_starts[line];
    let char_col = text[line_start_byte..byte].chars().count();
    (line, char_col)
}

Precompute line_starts: Vec<usize> by scanning text for \n byte positions (one pass, O(n)).

Write tests first:

#[test]
fn byte_mapping_single_line() {
    let text = "hello";
    let starts = line_start_bytes(text);
    assert_eq!(byte_to_line_char(&starts, text, 3), (0, 3));
}
#[test]
fn byte_mapping_second_line() {
    let text = "hi\nworld";
    let starts = line_start_bytes(text);
    assert_eq!(byte_to_line_char(&starts, text, 4), (1, 1)); // 'o' in "world"
}
#[test]
fn byte_mapping_multibyte() {
    let text = "café\nok";
    let starts = line_start_bytes(text);
    // 'é' is 2 bytes; byte offset 5 = start of second line
    assert_eq!(byte_to_line_char(&starts, text, 5), (1, 0));
}

Step 6.3 — build_decoration_map signature #

pub fn build_decoration_map(
    text: &str,
    theme: &Theme,
    italic_support: bool,
    cursor_line: usize,
) -> DecorationMap

This signature is the v1.5 migration seam. Do not change it. When v1.5 moves this to a background thread, only the call site changes.

Use pulldown-cmark's offset iterator:

let parser = Parser::new_with_broken_link_callback(
    text,
    Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS | Options::ENABLE_STRIKETHROUGH,
    None,
).into_offset_iter();

Step 6.4 — Implement each element #

Work through elements in this order (simpler to more complex). Before implementing each one, write a test that asserts the correct span is present in the map. Use the fixture file or an inline string. Example pattern:

fn make_theme() -> Theme { Theme::from_palette(&Palette::default(), &Default::default(), &Default::default()) }

#[test]
fn heading_h1_has_full_line_bg() {
    let text = "# Hello World";
    let map = build_decoration_map(text, &make_theme(), true, 99);
    let spans = map.get(&0).expect("line 0 should have spans");
    assert!(spans.iter().any(|s| s.full_line_bg.is_some()));
}

Write the equivalent for each element below before implementing it.

a. Headings (H1–H6)

Event::Start(Tag::Heading { level, .. }) with range covering the full line. Apply:

  • H1/H2: bold + accent_color (or per-level override from theme.headings)
  • H3+: accent_color only, no bold
  • All headings: set full_line_bg = Some(theme.heading_bg) on every span in the line

b. Bold

Event::Start(Tag::Strong) range includes ** delimiters. Apply bold to the whole range. For the delimiter characters (first 2 and last 2 chars of range): blend(text_color, muted, delimiter_blend).

c. Italic

Event::Start(Tag::Emphasis) range includes * delimiters. Apply italic_color; apply Modifier::ITALIC only if italic_support is true. Delimiter characters: blend(italic_color, muted, delimiter_blend).

d. Inline code

Event::Code(_) range includes backtick delimiters. Apply code_color fg + code_bg background to the whole range.

e. Fenced code blocks

Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) gives start; Event::End(...) gives end. Apply fenced_bg background to every line in the range.

// TODO(v1.5): pass block content and language tag to syntect here
// language tag is available from pulldown-cmark's CodeBlock(CodeBlockKind::Fenced(lang)) event
// replace fenced_bg-only spans with syntect-highlighted spans that retain fenced_bg as background

f. Blockquotes

Event::Start(Tag::BlockQuote) range spans all lines. For each line in range:

  • Prepend a ▌ (U+258C) character in muted color at char_start = 0, char_end = 1
  • Apply blockquote_color to the remaining line content
  • Set is_blockquote = true on all spans in the line (renderer uses this for continuation indent)

g. Links

Event::Start(Tag::Link { dest_url, .. }) range covers [text](url). Parse the range text to find the ]( boundary:

  • [ … ] portion: link_text color + underline; [ and ] characters use delimiter color
  • ( … ) portion: link_url color + italic (if supported); ( and ) use delimiter color

h. Lists and bullets

Event::Start(Tag::Item) gives each item's range. Identify the bullet/number character at the start of the line (char 0 or after indentation) and apply accent_color to it. List content remains normal style.

i. Todo items

Event::TaskListMarker(checked):

  • false (unchecked - [ ]): [ and ] in accent_color, space between in normal style
  • true (checked - [x]): entire item line in muted + Modifier::CROSSED_OUT

j. Tables (GFM)

Event::Start(Tag::TableHead) → header cells: bold + accent_color. Pipe characters | throughout the table: muted style. Cell content: normal. No column alignment in v1.

// TODO(v3): table column alignment, pretty borders, allow_table_overflow

Step 6.5 — Cursor line exclusion #

Write test first:

#[test]
fn cursor_line_has_no_decoration() {
    let text = "# Heading\nnormal line";
    let map = build_decoration_map(text, &make_theme(), true, 0); // cursor on heading line
    assert!(map.get(&0).map_or(true, |v| v.is_empty()),
        "cursor line should have no decoration");
    assert!(map.get(&1).is_some()); // non-cursor line unaffected (or absent if no decoration)
}

After building the full map, remove the cursor line's entries:

map.remove(&cursor_line);

This ensures raw Markdown syntax is visible while editing that line.

Step 6.6 — Word count #

Write test first:

#[test]
fn word_count_excludes_markdown_syntax() {
    // "**hello**" is 1 word, not 3 (** should not be counted)
    assert_eq!(count_words("**hello**"), 1);
    assert_eq!(count_words("# Title\n\nTwo words."), 3);
    assert_eq!(count_words(""), 0);
}

Run a second pass (reuse the same debounce window — do not add a separate timer):

pub fn count_words(text: &str) -> usize {
    Parser::new(text)
        .filter_map(|e| match e {
            Event::Text(s) | Event::Code(s) => Some(s.split_whitespace().count()),
            _ => None,
        })
        .sum()
}

Acceptance: All per-element tests written during Step 6.4 pass. Every element type in tests/fixtures/sample.md is covered by at least one test assertion. cargo test is green.


Phase 7 — Custom Renderer #

Goal: The editor area renders with full Markdown decoration, cursor, and selection highlighting.

Step 7.1 — MarkdownView widget in renderer.rs #

pub struct MarkdownView<'a> {
    pub lines: &'a [String],
    pub decoration_map: &'a DecorationMap,
    pub scroll_top: usize,
    pub cursor: (usize, usize),
    pub selection: Option<((usize, usize), (usize, usize))>,
    pub theme: &'a Theme,
    pub italic_support: bool,
    pub column_width: u16,
}

impl Widget for MarkdownView<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) { ... }
}

Step 7.2 — Line rendering pipeline #

For each visible logical line i (from scroll_top to scroll_top + visible_height):

  1. Get lines[i] as the raw string
  2. Soft-wrap it into visual rows (see Step 7.5)
  3. For each visual row, look up decoration spans intersecting that row's char range
  4. Split the row string at span boundaries into ratatui::text::Span objects
  5. Expand full_line_bg spans to fill column_width
  6. Apply selection overlay (see Step 7.4)
  7. Write the resulting Line into buf at the correct row offset

Step 7.3 — Span boundary splitting #

Given a raw line string and sorted Vec<StyledSpan>:

chars [0..span1.char_start]        → default style
chars [span1.char_start..char_end] → span1.style
chars [span1.char_end..span2.char_start] → default style
...

Write tests first:

#[test]
fn span_split_no_spans_returns_whole_line() {
    let result = split_into_spans("hello world", &[], Style::default());
    assert_eq!(result.len(), 1);
    assert_eq!(result[0].content, "hello world");
}
#[test]
fn span_split_mid_span_correct_boundaries() {
    let span = StyledSpan { char_start: 2, char_end: 5, style: bold_style(), ..Default::default() };
    let result = split_into_spans("0123456789", &[span], Style::default());
    assert_eq!(result[0].content, "01");   // unstyled prefix
    assert_eq!(result[1].content, "234");  // styled span
    assert_eq!(result[2].content, "56789"); // unstyled suffix
}
#[test]
fn span_split_multibyte_safe() {
    // 'é' is 2 bytes; char index 1 should split correctly
    let span = StyledSpan { char_start: 1, char_end: 2, ..Default::default() };
    let result = split_into_spans("café", &[span], Style::default());
    assert_eq!(result[0].content, "c");
    assert_eq!(result[1].content, "a");
}

Use str::char_indices() to convert char ranges to byte slices for safe &str slicing. Spans must not overlap; if they do (shouldn't in v1), clip the later span's start to the prior span's end rather than panicking.

Step 7.4 — Selection overlay #

Get selection from textarea.selection_range(). For each character in the selection range (across all visual rows of the selection), replace the cell's style entirely with Style::default().fg(theme.selection_fg).bg(theme.selection_bg). This is a full fg+bg override — span colors are discarded within the selection.

Apply selection after all other styling, as a final pass over the affected rows in buf.

Step 7.5 — Soft-wrap #

Write tests first:

#[test]
fn wrap_short_line_unchanged() {
    assert_eq!(wrap_line("hello", 40), vec!["hello"]);
}
#[test]
fn wrap_breaks_at_word_boundary() {
    let result = wrap_line("one two three four five", 12);
    assert!(result[0].len() <= 12);
    assert!(result.iter().all(|s| s.len() <= 12));
}
#[test]
fn wrap_hard_breaks_unbreakable_word() {
    let long = "abcdefghijklmnop";
    let result = wrap_line(long, 8);
    assert_eq!(result[0], "abcdefgh");
    assert_eq!(result[1], "ijklmnop");
}
#[test]
fn wrap_multibyte_respects_char_width() {
    // each kanji is 1 char; 4 kanji should fit in width=4
    let result = wrap_line("日本語テ", 4);
    assert_eq!(result.len(), 1);
}
fn wrap_line<'a>(s: &'a str, width: usize) -> Vec<&'a str> {
    // break at last space before width; hard-break if no space
}

Blockquote continuation: when is_blockquote is true on any span in the line, indent continuation visual rows by 2 chars (to align with text after ▌ ).

scroll_top is in logical-line space. The renderer converts to visual rows on each draw. This means scrolling can jump by >1 visual row when a long wrapped line scrolls off the top — acceptable for v1.

Step 7.6 — Cursor rendering #

After writing all spans for a line, set the cell at the cursor position:

let cell = buf.get_mut(area.x + cursor_col as u16, area.y + cursor_visual_row as u16);
cell.set_style(Style::default().fg(theme.bg).bg(theme.accent));

Step 7.7 — Wire into event loop #

terminal.draw(|f| {
    let layout = compute_layout(f.area(), app.config.layout.min_cols);
    let view = MarkdownView {
        lines: app.textarea.lines(),
        decoration_map: &app.decoration_map,
        scroll_top: app.scroll_top,
        cursor: app.textarea.cursor(),
        selection: get_selection(&app.textarea),
        theme: &app.theme,
        italic_support: app.italic_support,
        column_width: layout.column.width,
    };
    f.render_widget(view, layout.column);
    render_status_bar(f, layout.status_bar, &app);
    render_info_line(f, layout.info_line, &app);
    f.render_stateful_widget(scrollbar, layout.scrollbar, &mut scrollbar_state);
})?;

Acceptance: A real Markdown file opens and renders with decoration. Headings have tinted backgrounds. Bold and italic are styled. Cursor is visible. Selection highlights correctly.


Phase 8 — Debounce Loop & Decoration Trigger #

Goal: The decoration pass runs debounced at 50ms, single-threaded, triggered by keystroke timing.

Step 8.1 — Debounce in the event loop #

const DEBOUNCE: Duration = Duration::from_millis(50);
const POLL_TIMEOUT: Duration = Duration::from_millis(16);

loop {
    // Fire decoration pass if debounce has elapsed
    if let Some(t) = app.last_keystroke {
        if t.elapsed() >= DEBOUNCE {
            let text = app.textarea.lines().join("\n");
            let cursor_line = app.textarea.cursor().0;
            app.decoration_map = build_decoration_map(&text, &app.theme, app.italic_support, cursor_line);
            app.word_count = count_words(&text);
            app.last_keystroke = None;
        }
    }
    app.status.tick();  // clear expired timed messages

    terminal.draw(|f| { ... })?;

    if event::poll(POLL_TIMEOUT)? {
        // process event, set app.last_keystroke = Some(Instant::now()) on keystroke
    }
}

Seam note: build_decoration_map and count_words are pure functions. When v1.5 moves them to a background thread, the call site becomes tx.send(text) + rx.try_recv(). No other changes are needed.

Acceptance: Type rapidly in a long file. Decoration updates ~50ms after typing stops. The app stays responsive during fast typing.


Phase 9 — File Operations & Edit Behaviors #

Goal: Save, exit flow, copy/paste, and dirty tracking all work correctly.

Step 9.1 — Save (Ctrl+S) in main.rs #

fn handle_save(app: &mut App) -> io::Result<()> {
    let content = app.textarea.lines().join("\n");
    fs::write(&app.file_path, &content)?;
    app.saved_content = Some(app.textarea.lines().to_vec());
    app.is_dirty = false;
    app.status.set_timed("Saved.", Duration::from_millis(1500));
    Ok(())
}

On write error: app.status.set_dismissible(format!("⚠ Save failed: {err}")). Do not crash.

Step 9.2 — Exit flow (Ctrl+X) #

fn handle_exit(app: &mut App) -> ExitAction {
    if app.is_dirty {
        app.status.mode = StatusMode::ExitPrompt;
        ExitAction::Continue
    } else {
        ExitAction::Quit
    }
}

When ExitPrompt is active, intercept key events before textarea:

  • Y/y: save, then quit
  • N/n: quit without saving
  • Escape, C, or c: app.status.mode = StatusMode::Normal, resume editing

Acceptance: Edit a file, press Ctrl+X, confirm the prompt appears, press Y, file is saved, editor exits.

Step 9.3 — Clipboard (clipboard.rs) #

pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
    arboard::Clipboard::new()
        .and_then(|mut cb| cb.set_text(text.to_owned()))
        .map_err(|e| e.to_string())
}

pub fn paste_from_clipboard() -> Result<String, String> {
    arboard::Clipboard::new()
        .and_then(|mut cb| cb.get_text())
        .map_err(|e| e.to_string())
}

Copy: if textarea has a selection, copy selected text (read from textarea.lines() sliced by selection range — no soft-wrap breaks). Otherwise copy the current line.

Paste: textarea.insert_str(&text) with the clipboard content.

On error: app.status.set_dismissible(format!("⚠ Clipboard unavailable: {err}")).

Acceptance: Copy a line, paste into another terminal window. Works on macOS, Linux X11/Wayland.


Phase 10 — Polish & Edge Cases #

Goal: All warnings work, edge cases are handled, and the editor is solid on narrow and wide terminals.

Step 10.1 — Dismissible warnings #

On any key event, if status.mode is DismissibleMessage, call status.dismiss() before processing the key normally. Cover all design doc cases:

  • Italic unsupported warning (queued at startup)
  • Clipboard error
  • Config parse warning

Step 10.2 — Config warning banner #

For app.config_warnings: Vec<String> (populated during config loading), render at the top of the editing column (above first content line) with warning fg on ui_bar bg:

⚠ Config warning: Invalid color value for theme.accent, using default  [any key to dismiss]

Dismiss on any keypress (same mechanism as status bar dismissible messages, but clears config_warnings instead).

Step 10.3 — Narrow terminal handling #

compute_layout must never return negative widths or zero-height areas. Clamp:

let col_width = col_width.min(area.width);
let content_height = area.height.saturating_sub(2);

When area.width < min_cols: margins are 0, editing column fills full pane width.

Step 10.4 — Resize handling #

Event::Resize requires no special handling — the next terminal.draw() call receives the new dimensions from f.area(). Verify that scroll_top clamping runs against the new dimensions each frame, not cached old ones.

Step 10.5 — Multi-byte character safety #

All &str slicing by index must go through char-boundary-safe paths. The byte_to_line_char helper (Phase 6.2) handles the decoration map. In the renderer, use str::char_indices() or chars().nth() rather than direct byte indexing. Add a helper if needed:

fn char_to_byte(s: &str, char_idx: usize) -> usize {
    s.char_indices().nth(char_idx).map(|(i, _)| i).unwrap_or(s.len())
}

Phase 11 — Integration Test & Coverage Audit #

Unit tests for pure functions have been written alongside the code throughout Phases 2–7. CI has been running since Phase 0. This phase closes the gaps.

Step 11.1 — Audit unit test coverage #

Walk the test table below and confirm every row has at least one #[cfg(test)] test in the named module. Add any that were skipped during implementation:

Module Must be covered
config.rs blend() boundary cases, color parse success/failure, XDG env var override, all derived tokens with default palette, override precedence
decoration.rs Every element in sample.md, cursor line exclusion, multi-byte chars in headings and bold, count_words excluding syntax
layout.rs Width < min_cols, width = min_cols, wide terminal, status/info row positions
status.rs Timed message expiry, dismissible clears on dismiss, normal mode after clear
renderer.rs split_into_spans boundaries and multi-byte safety, wrap_line word/hard/multibyte cases

Step 11.2 — Integration test against the fixture file #

tests/integration.rs — reads tests/fixtures/sample.md from disk and runs the full decoration pass:

#[test]
fn fixture_decoration_roundtrip() {
    let text = include_str!("fixtures/sample.md");
    let theme = Theme::from_palette(&Palette::default(), &Default::default(), &Default::default());
    let map = build_decoration_map(text, &theme, true, 9999); // cursor far away

    // Heading line 0 ("# Heading One") has full_line_bg
    assert!(map[&0].iter().any(|s| s.full_line_bg.is_some()));

    // A bold span exists somewhere in the file
    assert!(map.values().flatten().any(|s| s.style.add_modifier.contains(Modifier::BOLD)));

    // A blockquote span exists and has is_blockquote set
    assert!(map.values().flatten().any(|s| s.is_blockquote));

    // Word count is non-zero
    assert!(count_words(text) > 100);
}

Acceptance: cargo test is green. cargo clippy -- -D warnings produces no warnings. CI passes.


Phase 12 — README & Distribution #

Step 12.1 — README content #

Per the design doc's README requirements:

  1. Install: cargo install --path . from source (crates.io publish is a separate step)
  2. Shell wrapper: full yame() function for .bashrc/.zshrc, fd/fzf note, find fallback
  3. Config: path, full base palette with Catppuccin Mocha defaults, override table, heading overrides
  4. Keybinding reference: the table from the design doc
  5. Nerd Fonts note: Powerline separators in the status bar require a patched font
  6. Optional deps: fd, fzf for the shell wrapper

Step 12.2 — Cargo.toml publishing metadata #

[package]
name = "yame"
version = "0.1.0"
edition = "2021"
description = "A lightweight terminal Markdown editor"
license = "MIT"
exclude = [".chainlink", "_docs"]
keywords = ["markdown", "editor", "terminal", "tui"]
categories = ["command-line-utilities", "text-editors"]

Seams for Future Phases (Do Not Implement in v1) #

Mark with comments in code; do not build the feature:

v1.5 — Background decoration thread build_decoration_map is already isolated as a free function. Change the call site to std::thread::spawn + mpsc::channel. No other changes needed. Leave a // TODO(v1.5): move to background thread comment at the call site in the event loop.

v1.5 — syntect fenced block highlighting The // TODO(v1.5): comment in the fenced block handler (Phase 6.4e) is the exact integration point. The lang tag from CodeBlockKind::Fenced(lang) is already captured there.

v1.5 — Config reload (Ctrl+R) Leave a // TODO(v1.5): Ctrl+R reloads config comment at the key-intercept point in the event loop. Config loading is already factored into config.rs.

v2 — Line numbers Parse and store [ui] show_line_numbers = false (as false). Leave a // TODO(v2): line numbers gutter comment in compute_layout.

v3 — Table alignment Parse and store [layout] allow_table_overflow = true. Leave a // TODO(v3): table column alignment comment in the table handling block of build_decoration_map.


Build Order Summary #

Phase Deliverable Depends On
0 Compiling scaffold + CI pipeline green —
1 Terminal opens/closes cleanly 0
2 Theme computes correctly 0
3 Buffer is live-editable 1, 2
4 Layout rectangles correct 1
5 Status bar & info line visible 2, 4
6 Decoration map built correctly 2
7 Full decorated render 3, 4, 5, 6
8 Debounce loop wired 6, 7
9 Save/exit/clipboard work 3, 5
10 Edge cases handled 7, 9
11 Integration test + coverage audit all
12 README & publishing prep all

Critical Files #

  • src/decoration.rs — build_decoration_map and DecorationMap; the most complex business logic, drives all Markdown decoration, contains the v1.5 syntect seam
  • src/renderer.rs — the custom MarkdownView widget; span-splitting, selection overlay, and soft-wrap all live here
  • src/main.rs — terminal lifecycle (critical for DisableMouseCapture on every exit path), event loop with debounce, and key-intercept ordering
  • src/config.rs — blend(), color parsing, derived token computation, XDG-aware config loading with graceful error handling
  • src/layout.rs — compute_layout and EditorLayout; all rendering depends on correct rectangle computation across terminal sizes