// Renderer — rutle's layout + paint layer (layers 2 & 3).
//
// Lays a `tdoc::Document` out against a backend-agnostic `RenderContext` (the
// layout phase, `layout_*`) and paints the result (`draw_*`). Also owns the
// view state a host needs: viewport/scroll, cursor blink, link hover, search,
// and hit-testing. Completely decoupled from markdown syntax.

use super::editor::*;
use super::reveal::{RevealReconciler, RevealStyle, item_reveal_styles, reveal_styles};
use super::structured_document::*;
use super::tree_path::{DocumentPosition, PathSegment, TreePath};
use super::tree_walk::{self, LeafInfo};
use crate::render_context::CaretLean;
use crate::render_context::FontStyle;
use crate::render_context::FontType;
use crate::render_context::RenderContext;
use crate::theme::{FontSettings, Theme};

/// A search match in the document
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchMatch {
    /// Block index where the match starts
    pub block_index: usize,
    /// Character offset within the block where match starts
    pub start_offset: usize,
    /// Character offset within the block where match ends (exclusive)
    pub end_offset: usize,
}

/// Layout information for a rendered line
#[derive(Debug, Clone)]
pub struct LayoutLine {
    /// Y position of the line's baseline
    y: i32,
    /// Height of the line
    height: i32,
    /// Default x position for the cursor when the line has no text runs
    base_x: i32,
    /// Block index this line belongs to
    block_index: usize,
    /// Character offset range within the block [start, end)
    char_start: usize,
    char_end: usize,
    /// Preferred visual end offset for cursor placement
    visual_char_end: usize,
    /// Visual elements on this line
    runs: Vec<VisualRun>,
}

impl LayoutLine {}

/// Geometry for drawing a table's grid lines and header fills. Computed during
/// layout and consumed by `draw`. Coordinates are in layout space (relative to
/// the widget's content origin, before scroll translation).
#[derive(Debug, Clone)]
struct TableLayout {
    /// Document block index of the table.
    block_index: usize,
    /// X of each vertical grid line, left to right (length = columns + 1).
    col_x: Vec<i32>,
    /// Y of each horizontal grid line, top to bottom (length = rows + 1).
    row_y: Vec<i32>,
}

/// Finalize the current visual line: record its char range and wrap flag, then
/// move it into `lines`. Shared by the wrapping engine and `push_token_wrapped`.
fn push_line(
    lines: &mut Vec<Vec<VisualRun>>,
    ranges: &mut Vec<(usize, usize)>,
    wraps: &mut Vec<bool>,
    current_line: &mut Vec<VisualRun>,
    default_offset: usize,
    wrapped: bool,
) {
    let (start, end) = if let Some(first) = current_line.first() {
        let last_end = current_line
            .last()
            .map(|r| r.char_range.1)
            .unwrap_or(first.char_range.1);
        (first.char_range.0, last_end)
    } else {
        (default_offset, default_offset)
    };
    ranges.push((start, end));
    wraps.push(wrapped);
    lines.push(std::mem::take(current_line));
}

/// Build a [`VisualRun`] for content text with the given resolved style.
fn run_from_style(
    text: String,
    x: i32,
    width: i32,
    char_range: (usize, usize),
    inline_index: usize,
    block_idx: usize,
    style: ResolvedRunStyle,
) -> VisualRun {
    VisualRun {
        text,
        x,
        width,
        font_type: style.font_type,
        font_style: style.font_style,
        font_size: style.font_size,
        font_color: style.font_color,
        background_color: style.background_color,
        underline: style.underline,
        strikethrough: style.strikethrough,
        block_index: block_idx,
        char_range,
        inline_index: Some(inline_index),
        checklist: None,
        reveal_tag: false,
    }
}

/// Place a single token (a word or a whole link) onto the current line, wrapping
/// to the next line when it doesn't fit.
///
/// When `break_long_words` is set and the token is wider than a full line, it is
/// split greedily at character boundaries so it can never bleed past the
/// available width — needed for narrow table cells. With the flag off, behavior
/// is identical to the original word wrapper (wrap once, then place whole).
#[allow(clippy::too_many_arguments)]
fn push_token_wrapped(
    ctx: &mut dyn RenderContext,
    lines: &mut Vec<Vec<VisualRun>>,
    line_ranges: &mut Vec<(usize, usize)>,
    line_wraps: &mut Vec<bool>,
    current_line: &mut Vec<VisualRun>,
    current_x: &mut i32,
    current_y: &mut i32,
    start_x: i32,
    width: i32,
    line_height: i32,
    break_long_words: bool,
    defer_trailing_space: bool,
    text: &str,
    char_base: usize,
    inline_index: usize,
    block_idx: usize,
    style: ResolvedRunStyle,
) {
    let (font, fstyle, size) = (style.font_type, style.font_style, style.font_size);
    let token_width = ctx.text_width(text, font, fstyle, size) as i32;

    let must_break = break_long_words && width > 0 && token_width > width;
    if !must_break {
        // A token carries its trailing whitespace. When `defer_trailing_space` is
        // set, that space must not push the wrap decision: a word whose glyphs fit
        // belongs on this line even when its trailing space would spill past the
        // edge (the space is then invisible at the line end). Testing the trimmed
        // width mirrors classic Pure, which held the inter-word space pending and
        // dropped it at the break. Off by default so pixel backends keep wrapping
        // on the full token width.
        let fit_width = if defer_trailing_space {
            let trimmed = text.trim_end_matches(|c: char| c.is_whitespace());
            if trimmed.len() == text.len() {
                token_width
            } else {
                ctx.text_width(trimmed, font, fstyle, size) as i32
            }
        } else {
            token_width
        };
        // Wrap to the next line if it doesn't fit and we're not already at the
        // line start, then place the whole token (original behavior).
        if *current_x + fit_width > start_x + width && *current_x > start_x {
            push_line(
                lines,
                line_ranges,
                line_wraps,
                current_line,
                char_base,
                true,
            );
            *current_x = start_x;
            *current_y += line_height;
        }
        current_line.push(run_from_style(
            text.to_string(),
            *current_x,
            token_width,
            (char_base, char_base + text.len()),
            inline_index,
            block_idx,
            style,
        ));
        *current_x += token_width;
        return;
    }

    // The token is wider than a full line: break it greedily, character by
    // character, so it can never overflow into the neighbouring column.
    let indices: Vec<usize> = text.char_indices().map(|(i, _)| i).collect();
    let char_count = indices.len();
    let byte_end_of = |k: usize| {
        if k + 1 < char_count {
            indices[k + 1]
        } else {
            text.len()
        }
    };

    let mut seg_start = 0usize;
    let mut k = 0usize;
    while k < char_count {
        // Grow the chunk while it still fits from the current x.
        let mut fit_end = seg_start;
        let mut fit_k = k;
        while fit_k < char_count {
            let cand_end = byte_end_of(fit_k);
            let w = ctx.text_width(&text[seg_start..cand_end], font, fstyle, size) as i32;
            if *current_x + w > start_x + width {
                break;
            }
            fit_end = cand_end;
            fit_k += 1;
        }

        if fit_end > seg_start {
            let chunk = &text[seg_start..fit_end];
            let w = ctx.text_width(chunk, font, fstyle, size) as i32;
            current_line.push(run_from_style(
                chunk.to_string(),
                *current_x,
                w,
                (char_base + seg_start, char_base + fit_end),
                inline_index,
                block_idx,
                style,
            ));
            *current_x += w;
            seg_start = fit_end;
            k = fit_k;
        } else if *current_x == start_x {
            // Not even one character fits on a fresh line (column narrower than a
            // glyph). Force one through to guarantee forward progress.
            let cand_end = byte_end_of(k);
            let chunk = &text[seg_start..cand_end];
            let w = ctx.text_width(chunk, font, fstyle, size) as i32;
            current_line.push(run_from_style(
                chunk.to_string(),
                *current_x,
                w,
                (char_base + seg_start, char_base + cand_end),
                inline_index,
                block_idx,
                style,
            ));
            *current_x += w;
            seg_start = cand_end;
            k += 1;
        }

        // More to place: wrap to the next line.
        if seg_start < text.len() {
            push_line(
                lines,
                line_ranges,
                line_wraps,
                current_line,
                char_base + seg_start,
                true,
            );
            *current_x = start_x;
            *current_y += line_height;
        }
    }
}

/// Word-wrap one styled text run onto the running line(s): emit any leading
/// whitespace as its own run, then place each word (with its trailing
/// whitespace) via [`push_token_wrapped`]. Shared by the plain-text path and the
/// per-span link path so links keep each span's own weight/slant while the
/// wrapping stays identical. `char_base` is the byte offset of `text` within the
/// leaf; `inline_index` is the owning inline element's index in the block.
#[allow(clippy::too_many_arguments)]
fn layout_styled_text(
    ctx: &mut dyn RenderContext,
    lines: &mut Vec<Vec<VisualRun>>,
    line_ranges: &mut Vec<(usize, usize)>,
    line_wraps: &mut Vec<bool>,
    current_line: &mut Vec<VisualRun>,
    current_x: &mut i32,
    current_y: &mut i32,
    start_x: i32,
    width: i32,
    line_height: i32,
    break_long_words: bool,
    defer_trailing_space: bool,
    text: &str,
    char_base: usize,
    inline_index: usize,
    block_idx: usize,
    style: ResolvedRunStyle,
) {
    let font = style.font_type;
    let fstyle = style.font_style;
    let size = style.font_size;

    let mut word_start = 0;
    let mut in_word = false;
    let mut leading_space_handled = false;

    for (i, ch) in text
        .char_indices()
        .chain(std::iter::once((text.len(), ' ')))
    {
        let is_whitespace = ch.is_whitespace();

        // Handle leading whitespace at the start of the run
        if !leading_space_handled && is_whitespace && i == 0 {
            // Find all leading whitespace
            let mut space_end = 0;
            for (idx, c) in text.char_indices() {
                if c.is_whitespace() {
                    space_end = idx + c.len_utf8();
                } else {
                    break;
                }
            }

            if space_end > 0 {
                let space_text = &text[..space_end];
                let space_width = ctx.text_width(space_text, font, fstyle, size) as i32;

                current_line.push(VisualRun {
                    text: space_text.to_string(),
                    x: *current_x,
                    width: space_width,
                    font_type: style.font_type,
                    font_style: style.font_style,
                    font_size: style.font_size,
                    font_color: style.font_color,
                    background_color: style.background_color,
                    underline: style.underline,
                    strikethrough: style.strikethrough,
                    block_index: block_idx,
                    char_range: (char_base, char_base + space_end),
                    inline_index: Some(inline_index),
                    checklist: None,
                    reveal_tag: false,
                });

                *current_x += space_width;
            }
            leading_space_handled = true;
        }

        if in_word && (is_whitespace || i == text.len()) {
            // End of word - extract word with trailing whitespace
            let mut word_end = i;
            // Include trailing whitespace in the word
            while word_end < text.len()
                && text[word_end..]
                    .chars()
                    .next()
                    .is_some_and(|c| c.is_whitespace() && c != '\n')
            {
                word_end += text[word_end..].chars().next().unwrap().len_utf8();
            }

            let word_text = &text[word_start..word_end];
            push_token_wrapped(
                ctx,
                lines,
                line_ranges,
                line_wraps,
                current_line,
                current_x,
                current_y,
                start_x,
                width,
                line_height,
                break_long_words,
                defer_trailing_space,
                word_text,
                char_base + word_start,
                inline_index,
                block_idx,
                style,
            );
            in_word = false;
        } else if !in_word && !is_whitespace {
            // Start of new word
            word_start = i;
            in_word = true;
        }
    }
}

/// Place a reveal-codes tag (e.g. `[Bold>`) onto the current line as a
/// zero-document-width, cursor-skipped run, wrapping to the next line if it
/// doesn't fit. The tag occupies visual space — so following text wraps and the
/// caret lands past it — but spans no document text: its `char_range` is the
/// empty `(char_offset, char_offset)` and it carries no `inline_index`, which is
/// exactly the shape the cursor/column logic skips.
#[allow(clippy::too_many_arguments)]
fn push_reveal_tag(
    ctx: &mut dyn RenderContext,
    lines: &mut Vec<Vec<VisualRun>>,
    line_ranges: &mut Vec<(usize, usize)>,
    line_wraps: &mut Vec<bool>,
    current_line: &mut Vec<VisualRun>,
    current_x: &mut i32,
    current_y: &mut i32,
    start_x: i32,
    width: i32,
    line_height: i32,
    label: &str,
    char_offset: usize,
    block_idx: usize,
    style: ResolvedRunStyle,
) {
    let tag_width =
        ctx.text_width(label, style.font_type, style.font_style, style.font_size) as i32;
    if *current_x + tag_width > start_x + width && *current_x > start_x {
        push_line(
            lines,
            line_ranges,
            line_wraps,
            current_line,
            char_offset,
            true,
        );
        *current_x = start_x;
        *current_y += line_height;
    }
    current_line.push(VisualRun {
        text: label.to_string(),
        x: *current_x,
        width: tag_width,
        font_type: style.font_type,
        font_style: style.font_style,
        font_size: style.font_size,
        font_color: style.font_color,
        background_color: style.background_color,
        underline: style.underline,
        strikethrough: style.strikethrough,
        block_index: block_idx,
        char_range: (char_offset, char_offset),
        inline_index: None,
        checklist: None,
        reveal_tag: true,
    });
    *current_x += tag_width;
}

/// Emit the reveal tags at one boundary: end tags (`<Name]`, innermost first)
/// then start tags (`[Name>`, outermost first), all at `char_offset`.
#[allow(clippy::too_many_arguments)]
fn emit_reveal_tags(
    ctx: &mut dyn RenderContext,
    lines: &mut Vec<Vec<VisualRun>>,
    line_ranges: &mut Vec<(usize, usize)>,
    line_wraps: &mut Vec<bool>,
    current_line: &mut Vec<VisualRun>,
    current_x: &mut i32,
    current_y: &mut i32,
    start_x: i32,
    width: i32,
    line_height: i32,
    char_offset: usize,
    block_idx: usize,
    style: ResolvedRunStyle,
    closes: &[RevealStyle],
    opens: &[RevealStyle],
) {
    for s in closes {
        push_reveal_tag(
            ctx,
            lines,
            line_ranges,
            line_wraps,
            current_line,
            current_x,
            current_y,
            start_x,
            width,
            line_height,
            &format!("<{}]", s.label()),
            char_offset,
            block_idx,
            style,
        );
    }
    for s in opens {
        push_reveal_tag(
            ctx,
            lines,
            line_ranges,
            line_wraps,
            current_line,
            current_x,
            current_y,
            start_x,
            width,
            line_height,
            &format!("[{}>", s.label()),
            char_offset,
            block_idx,
            style,
        );
    }
}

/// Return a copy of inline content with every text run forced to bold. Used for
/// table header cells, which the renderer draws in bold like the tdoc CLI.
fn bolden_inline(content: &[InlineContent]) -> Vec<InlineContent> {
    content
        .iter()
        .map(|item| match item {
            InlineContent::Text(run) => {
                let mut style = run.style;
                style.bold = true;
                InlineContent::Text(TextRun::new(run.text.clone(), style))
            }
            InlineContent::Link { link, content } => InlineContent::Link {
                link: link.clone(),
                content: bolden_inline(content),
            },
            InlineContent::HardBreak => InlineContent::HardBreak,
        })
        .collect()
}

/// Result of [`Renderer::content_line_metrics`]: the data a cell
/// backend needs to report a classic-Pure "content line" number in its status
/// bar without baking layout internals into the app.
pub struct ContentLineMetrics {
    /// Total laid-out content rows (block spacing and decorations excluded).
    pub total_lines: usize,
    /// Zero-based index of the cursor's layout line among all layout lines.
    pub cursor_line_ordinal: Option<usize>,
    /// Index into `Document.paragraphs` of the top-level block holding the cursor.
    pub cursor_root_paragraph: Option<usize>,
}

/// Classic-Pure (top, bottom) block margins, in line-height units, used when
/// `Theme::classic_block_spacing` is on. Only headings carry margins; every
/// other block relies on the base inter-block gap.
fn classic_margins(block_type: &BlockType) -> (i32, i32) {
    match block_type {
        BlockType::Heading { level: 1 } => (3, 3),
        BlockType::Heading { level: 2 } => (3, 2),
        BlockType::Heading { .. } => (2, 1),
        _ => (0, 0),
    }
}

struct InlineContentLayout {
    lines: Vec<Vec<VisualRun>>,
    line_ranges: Vec<(usize, usize)>,
    line_wraps: Vec<bool>,
    y_after: i32,
}

/// Checklist marker rendering metadata
#[derive(Debug, Clone)]
struct ChecklistVisual {
    /// Whether the checklist item is checked
    checked: bool,
    /// Size of the checkbox square in pixels
    box_size: i32,
}

/// A visual run of text with styling
#[derive(Debug, Clone)]
struct VisualRun {
    /// Display text
    text: String,
    /// X position
    x: i32,
    /// Width of the text
    width: i32,
    /// Block index this belongs to
    block_index: usize,
    /// Character range within block
    char_range: (usize, usize),
    /// Inline content index (for link detection)
    inline_index: Option<usize>,
    /// Checklist rendering info (if this run is a checklist marker)
    checklist: Option<ChecklistVisual>,
    /// True for a reveal-codes tag run (`[Bold>`/`<Bold]`): a zero-document-width
    /// decoration the caret can still step onto (see `cursor_reveal_stop`).
    reveal_tag: bool,

    font_type: FontType,
    font_style: FontStyle,
    font_size: u8,
    font_color: u32,
    background_color: Option<u32>,
    underline: bool,
    strikethrough: bool,
    // Highlight not necessary, as we'll have a non-None background_color for that
}

#[derive(Clone, Copy)]
struct ResolvedRunStyle {
    font_type: FontType,
    font_style: FontStyle,
    font_size: u8,
    font_color: u32,
    background_color: Option<u32>,
    underline: bool,
    strikethrough: bool,
    // Highlight not necessary, as we'll have a non-None background_color for that
}

/// Lays out and paints a [`tdoc::Document`], and owns the interaction/view
/// state (viewport, scroll, cursor, hover, search) a host frontend drives.
pub struct Renderer {
    // Position and size
    x: i32,
    y: i32,
    w: i32,
    h: i32,

    // Editor (contains document and cursor)
    editor: Editor,

    // Layout cache
    layout_lines: Vec<LayoutLine>,
    // Grid geometry for table blocks, parallel to layout_lines (cell text lives
    // in layout_lines; borders/header fills are drawn from this).
    table_layouts: Vec<TableLayout>,
    // The leaves of the current layout frame, in document order. `block_index` on
    // layout lines/runs is an index into this (a transient projection of the
    // authoritative tdoc tree, rebuilt every layout — not a parallel document model).
    layout_leaves: Vec<LeafInfo>,
    // The renderable block for each leaf, parallel to `layout_leaves`.
    layout_blocks: Vec<Block>,
    // The x-position (relative to `self.x`) of each enclosing quote bar for every
    // leaf, outermost→innermost; empty for leaves outside a quote. Parallel to
    // `layout_leaves`. Precomputed at layout time because a bar's column depends on
    // the leaf's interleaved list nesting (see `quote_bar_positions`).
    layout_leaf_bars: Vec<Vec<i32>>,
    layout_valid: bool,
    // Monotonic counter bumped every time mutable access to the editor is handed
    // out (i.e. on every potential document mutation). Consumers key derived,
    // document-wide caches (word counts, outlines, …) off this so they only
    // recompute after an actual edit — not on every cursor move or redraw.
    edit_revision: u64,

    // Scrolling
    scroll_offset: i32,

    // Cursor display
    cursor_visible: bool,
    // Cursor blink state
    blink_on: bool,
    blink_period_ms: u64,
    // Most recent elapsed-ms value seen by `tick`; used as the time base when
    // re-anchoring the blink cycle on cursor movement.
    last_tick_ms: u64,
    // Elapsed-ms value at which the current blink cycle started (its "on" phase).
    blink_anchor_ms: u64,

    // Link hover state: the leaf path + inline index of the hovered link.
    hovered_link: Option<(TreePath, usize)>,

    // Sticky horizontal position for vertical navigation across proportional fonts
    cursor_preferred_line_offset: Option<usize>,

    // Search state
    search_term: String,
    search_matches: Vec<SearchMatch>,
    search_current_index: Option<usize>,

    // Theme
    theme: Theme,
}

impl Renderer {
    pub fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
        Renderer {
            x,
            y,
            w,
            h,
            editor: Editor::new(),
            layout_lines: Vec::new(),
            table_layouts: Vec::new(),
            layout_leaves: Vec::new(),
            layout_blocks: Vec::new(),
            layout_leaf_bars: Vec::new(),
            layout_valid: false,
            edit_revision: 0,
            scroll_offset: 0,
            cursor_visible: true,
            blink_on: true,
            blink_period_ms: 1000, // 1s full period (500ms on/off)
            last_tick_ms: 0,
            blink_anchor_ms: 0,
            hovered_link: None,
            cursor_preferred_line_offset: None,
            search_term: String::new(),
            search_matches: Vec::new(),
            search_current_index: None,
            theme: Theme::default(),
        }
    }

    /// Get the editor
    pub fn editor(&self) -> &Editor {
        &self.editor
    }

    /// Read-only access to the active theme.
    pub fn theme(&self) -> &Theme {
        &self.theme
    }

    /// Replace the active theme (colors, font settings, and layout metrics such
    /// as `line_height` and padding). Frontends with a different coordinate
    /// system — e.g. a terminal that measures in character cells rather than
    /// pixels — install a theme with cell-appropriate metrics here. Invalidates
    /// the cached layout.
    pub fn set_theme(&mut self, theme: Theme) {
        self.theme = theme;
        self.layout_valid = false;
    }

    /// Get mutable editor. Invalidates the cached layout and bumps the edit
    /// revision, since the caller may mutate the document through the returned
    /// reference.
    pub fn editor_mut(&mut self) -> &mut Editor {
        self.layout_valid = false;
        self.edit_revision += 1;
        &mut self.editor
    }

    /// A monotonic counter that increments on every [`editor_mut`](Self::editor_mut)
    /// call (every potential document mutation). Cheap to read; use it to guard
    /// expensive document-wide derived state so it is recomputed only after an
    /// edit rather than on every cursor move or frame.
    pub fn edit_revision(&self) -> u64 {
        self.edit_revision
    }

    /// Whether reveal-codes mode is active (inline-style tags shown inline).
    pub fn reveal_codes(&self) -> bool {
        self.editor.reveal_codes()
    }

    /// Enable/disable reveal-codes mode and invalidate the cached layout.
    pub fn set_reveal_codes(&mut self, enabled: bool) {
        self.editor.set_reveal_codes(enabled);
        self.layout_valid = false;
    }

    /// Set scroll offset
    pub fn set_scroll(&mut self, offset: i32) {
        self.scroll_offset = offset.max(0);
    }

    /// Get scroll offset
    pub fn scroll_offset(&self) -> i32 {
        self.scroll_offset
    }

    /// Ensure the cursor's line is visible by adjusting scroll offset
    /// Minimally scrolls to bring the cursor line within the viewport with small margins
    pub fn ensure_cursor_visible(&mut self, ctx: &mut dyn RenderContext) {
        // Ensure layout is up to date
        self.layout(ctx);

        // Get cursor visual position (content coords)
        if let Some((_cx, cy, ch)) = self.get_cursor_visual_position(ctx) {
            let viewport_top = self.scroll_offset;
            let viewport_bottom = self.scroll_offset + self.h;

            // Provide a small comfort margin around the cursor line
            let margin_top = self.theme.cursor_scroll_margin;
            let margin_bottom = self.theme.cursor_scroll_margin;

            let mut new_scroll = self.scroll_offset;

            // If above viewport (with margin), scroll up
            if cy < viewport_top + margin_top {
                new_scroll = (cy - margin_top).max(0);
            }

            // If below viewport (with margin), scroll down
            if cy + ch > viewport_bottom - margin_bottom {
                new_scroll = (cy + ch + margin_bottom - self.h).max(0);
            }

            // Clamp to content height
            let max_scroll = (self.content_height() - self.h).max(0);
            if new_scroll > max_scroll {
                new_scroll = max_scroll;
            }

            self.scroll_offset = new_scroll;
        }
    }

    /// Get content height
    pub fn content_height(&self) -> i32 {
        if let Some(last_line) = self.layout_lines.last() {
            last_line.y + last_line.height + self.theme.padding_vertical
        } else {
            0
        }
    }

    /// Resize the widget.
    ///
    /// Only the width affects layout (it drives wrapping); `x`/`y` merely
    /// translate the drawing origin and `h` only bounds the viewport. The
    /// layout cache is therefore invalidated solely when the width changes —
    /// callers that re-issue the same geometry every frame (the editor redraw
    /// loop does) keep their cached layout and pay nothing.
    pub fn resize(&mut self, x: i32, y: i32, w: i32, h: i32) {
        if self.w != w {
            self.layout_valid = false;
        }
        self.x = x;
        self.y = y;
        self.w = w;
        self.h = h;
    }

    /// Set horizontal padding (for write room mode). Idempotent: re-setting the
    /// current padding is a no-op and preserves the cached layout, so the redraw
    /// loop can call this unconditionally without forcing a re-layout.
    pub fn set_horizontal_padding(&mut self, padding: i32) {
        if self.theme.padding_horizontal == padding {
            return;
        }
        self.theme.padding_horizontal = padding;
        self.layout_valid = false;
    }

    /// Get current horizontal padding
    pub fn horizontal_padding(&self) -> i32 {
        self.theme.padding_horizontal
    }

    pub fn x(&self) -> i32 {
        self.x
    }
    pub fn y(&self) -> i32 {
        self.y
    }
    pub fn w(&self) -> i32 {
        self.w
    }
    pub fn h(&self) -> i32 {
        self.h
    }

    /// Perform layout
    fn layout(&mut self, ctx: &mut dyn RenderContext) {
        // Keep the editor's affinity capability in step with the backend: a cell
        // backend can't render the lean, so it reports
        // `supports_caret_affinity() == false` and the engine collapses the two
        // affinity stops into one (no extra navigation stop, no lean, left-biased
        // insertion). Runs before the layout-memoization early return — and `draw`
        // calls `layout` every frame — so navigation always sees the current
        // backend's capability. Idempotent and cheap.
        self.editor
            .set_affinity_supported(ctx.supports_caret_affinity());

        if self.layout_valid {
            return;
        }

        self.layout_lines.clear();
        self.table_layouts.clear();

        let content_width =
            self.w - 2 * self.theme.padding_horizontal - self.theme.wrap_width_reduction;
        let mut current_y = self.theme.padding_vertical;

        // Project the authoritative tdoc tree into a flat list of leaves (in document
        // order) and a parallel list of renderable blocks. `block_index` on layout lines
        // is an index into these vecs.
        let (leaves, blocks) = {
            let tdoc = self.editor.document();
            let leaves = tree_walk::enumerate_leaves(tdoc);
            let blocks: Vec<Block> = leaves
                .iter()
                .map(|info| Block {
                    block_type: tree_walk::leaf_block_type(tdoc, info),
                    content: tree_walk::leaf_inline(tdoc, &info.path),
                })
                .collect();
            (leaves, blocks)
        };

        let mut prev_bottom = 0;
        for block_idx in 0..blocks.len() {
            if self.theme.classic_block_spacing {
                // Classic Pure spacing: the gap before a block is the max of the
                // base inter-block gap, the previous block's bottom margin and
                // this block's top margin (margins collapse, they don't add).
                let lh = self.theme.line_height;
                let (top, bottom) = classic_margins(&blocks[block_idx].block_type);
                let base_gap = if block_idx > 0 { lh } else { 0 };
                current_y += base_gap.max(prev_bottom).max(top * lh);
                prev_bottom = bottom * lh;
            }
            current_y = self.layout_block(
                &blocks[block_idx],
                &blocks,
                &leaves,
                block_idx,
                leaves[block_idx].quote_depth,
                leaves[block_idx].list_levels,
                current_y,
                content_width,
                ctx,
            );
        }

        // Precompute the quote-bar columns for every leaf now that markers (whose
        // widths need `ctx`) are resolvable; `draw()` only reads them.
        let mut leaf_bars = Vec::with_capacity(blocks.len());
        for block_idx in 0..blocks.len() {
            leaf_bars.push(self.quote_bar_positions(&blocks, &leaves, block_idx, ctx));
        }

        self.layout_leaves = leaves;
        self.layout_blocks = blocks;
        self.layout_leaf_bars = leaf_bars;
        self.layout_valid = true;
    }

    /// The tree path of the leaf at frame index `idx` (empty path if out of range).
    fn path_for_index(&self, idx: usize) -> TreePath {
        self.layout_leaves
            .get(idx)
            .map(|info| info.path.clone())
            .unwrap_or_default()
    }

    /// The frame index of the leaf at `path`, if present in the current layout frame.
    fn index_for_path(&self, path: &TreePath) -> Option<usize> {
        self.layout_leaves
            .iter()
            .position(|info| &info.path == path)
    }

    /// Build a position from a frame leaf index and a byte offset.
    fn pos_at_index(&self, idx: usize, offset: usize) -> DocumentPosition {
        DocumentPosition::at(self.path_for_index(idx), offset)
    }

    /// Compute a precise character offset within a given line for a desired x position using font metrics.
    pub fn precise_offset_in_line(
        &self,
        line: &LayoutLine,
        x: i32,
        ctx: &mut dyn RenderContext,
    ) -> usize {
        let mut offset = line.char_start;
        for run in &line.runs {
            let run_end_x = run.x + run.width;
            if x < run.x {
                // Before this run: cursor snaps to beginning of this run
                return offset;
            }
            if x >= run.x && x < run_end_x {
                // Within this run: walk characters and measure
                let (font, fstyle, size) = (run.font_type, run.font_style, run.font_size);
                let mut last_offset = run.char_range.0;
                // Iterate char boundaries in the run's text
                for (i, _) in run
                    .text
                    .char_indices()
                    .chain(std::iter::once((run.text.len(), ' ')))
                {
                    let w = ctx.text_width(&run.text[..i], font, fstyle, size) as i32;
                    if run.x + w > x {
                        return last_offset;
                    }
                    last_offset = run.char_range.0 + i;
                }
                return run.char_range.1;
            }
            // After this run: advance offset
            offset = run.char_range.1;
        }
        offset
    }

    /// Find the index of the visual line containing the current cursor. If no exact match,
    /// returns the nearest line within the same block, otherwise None.
    fn is_last_visual_line_in_block(&self, line_index: usize) -> bool {
        if line_index >= self.layout_lines.len() {
            return true;
        }
        let block_idx = self.layout_lines[line_index].block_index;
        !self.layout_lines[line_index + 1..]
            .iter()
            .any(|line| line.block_index == block_idx)
    }

    fn offset_belongs_to_line(&self, line_index: usize, offset: usize) -> bool {
        if line_index >= self.layout_lines.len() {
            return false;
        }
        let line = &self.layout_lines[line_index];
        if line.char_start == line.char_end {
            return offset == line.char_start;
        }
        if offset < line.char_start {
            return false;
        }
        if offset < line.char_end {
            return true;
        }
        if offset == line.char_end && line.visual_char_end == line.char_end {
            return true;
        }
        self.is_last_visual_line_in_block(line_index) && offset == line.char_end
    }

    /// Get the character offset within the visual line for a given position.
    fn visual_line_offset(&self, pos: DocumentPosition) -> Option<usize> {
        let target = self.index_for_path(&pos.path)?;
        for (i, line) in self.layout_lines.iter().enumerate() {
            if line.block_index == target && self.offset_belongs_to_line(i, pos.offset) {
                return Some(pos.offset - line.char_start);
            }
        }
        None
    }

    fn compute_visual_char_end(&self, runs: &[VisualRun], char_end: usize, wrapped: bool) -> usize {
        if !wrapped {
            return char_end;
        }

        for run in runs.iter().rev() {
            if run.char_range.0 == run.char_range.1 {
                continue;
            }

            if run.text.is_empty() {
                continue;
            }

            let trimmed = run.text.trim_end_matches(|c: char| c.is_whitespace());
            if trimmed.len() == run.text.len() {
                return run.char_range.1;
            }

            if trimmed.is_empty() {
                continue;
            }

            let delta = run.text.len() - trimmed.len();
            return run.char_range.1.saturating_sub(delta);
        }

        char_end
    }

    fn current_line_index_for_cursor(&self) -> Option<usize> {
        let cursor = self.editor.cursor();
        self.line_index_for_pos(&cursor.path, cursor.offset)
    }

    /// The index of the visual line that owns `(path, offset)`. Prefers the line
    /// whose char range contains the offset; falls back to the closest line in
    /// the same block by char-range proximity, otherwise `None`.
    fn line_index_for_pos(&self, path: &TreePath, offset: usize) -> Option<usize> {
        let cidx = self.index_for_path(path)?;
        // First, look for a line in the same block whose char range contains the offset
        for (i, line) in self.layout_lines.iter().enumerate() {
            if line.block_index == cidx && self.offset_belongs_to_line(i, offset) {
                return Some(i);
            }
        }
        // Fallback: closest line in the same block by char range proximity
        let mut candidate: Option<(usize, usize)> = None; // (index, distance)
        for (i, line) in self.layout_lines.iter().enumerate() {
            if line.block_index == cidx {
                let dist = if offset < line.char_start {
                    line.char_start - offset
                } else {
                    offset.saturating_sub(line.char_end)
                };
                candidate = match candidate {
                    Some((_, best_dist)) if best_dist <= dist => candidate,
                    _ => Some((i, dist)),
                };
            }
        }
        candidate.map(|(i, _)| i)
    }

    pub fn record_preferred_pos(&mut self, pos: DocumentPosition) {
        self.cursor_preferred_line_offset = self.visual_line_offset(pos);
    }

    fn get_preferred_pos_for_line(&self, line: &LayoutLine) -> DocumentPosition {
        let preferred_offset = self
            .cursor_preferred_line_offset
            .unwrap_or(self.visual_line_offset(self.editor.cursor()).unwrap_or(0));

        let line_visual_len = line.visual_char_end.saturating_sub(line.char_start);
        let effective_offset = preferred_offset.min(line_visual_len);

        self.pos_at_index(line.block_index, line.char_start + effective_offset)
    }

    /// Whether the leaf at frame index `block_index` is a (read-only) table.
    fn block_is_table(&self, block_index: usize) -> bool {
        matches!(
            self.layout_blocks.get(block_index).map(|b| &b.block_type),
            Some(BlockType::Table { .. })
        )
    }

    /// Resolve the cursor position for a vertical move that landed on
    /// `target_idx`. A table is a single stop, so landing on any of its lines
    /// places the cursor at the table's start; other blocks honor the preferred
    /// (sticky) column.
    fn vertical_target_pos(&self, target_idx: usize) -> DocumentPosition {
        let line = &self.layout_lines[target_idx];
        if self.block_is_table(line.block_index) {
            self.pos_at_index(line.block_index, 0)
        } else {
            self.get_preferred_pos_for_line(line)
        }
    }

    /// Move cursor one visual line up, using wrapped lines when applicable.
    /// When `extend` is true, extends the selection (Shift+Up behavior).
    pub fn move_cursor_visual_up(&mut self, extend: bool, ctx: &mut dyn RenderContext) {
        // Ensure layout is current for measurement
        self.layout(ctx);
        if self.layout_lines.is_empty() {
            if extend {
                self.editor.move_cursor_up_extend();
            } else {
                self.editor.move_cursor_up();
            }
            return;
        }

        let cur_idx = match self.current_line_index_for_cursor() {
            Some(i) => i,
            None => {
                if extend {
                    self.editor.move_cursor_up_extend();
                } else {
                    self.editor.move_cursor_up();
                }
                return;
            }
        };
        if cur_idx == 0 {
            // Already at the first line
            return;
        }

        // Treat a table as a single stop: skip up over all of its lines to the
        // line directly above it.
        let cur_block = self.layout_lines[cur_idx].block_index;
        let target_idx = if self.block_is_table(cur_block) {
            let mut first = cur_idx;
            while first > 0 && self.layout_lines[first - 1].block_index == cur_block {
                first -= 1;
            }
            if first == 0 {
                return; // table is the first block; nothing above
            }
            first - 1
        } else {
            cur_idx - 1
        };

        let new_pos = self.vertical_target_pos(target_idx);
        if extend {
            self.editor.extend_selection_to(new_pos);
        } else {
            self.editor.set_cursor(new_pos);
        }
    }

    /// Move cursor one visual line down, using wrapped lines when applicable.
    /// When `extend` is true, extends the selection (Shift+Down behavior).
    pub fn move_cursor_visual_down(&mut self, extend: bool, ctx: &mut dyn RenderContext) {
        self.layout(ctx);
        if self.layout_lines.is_empty() {
            if extend {
                self.editor.move_cursor_down_extend();
            } else {
                self.editor.move_cursor_down();
            }
            return;
        }

        let cur_idx = match self.current_line_index_for_cursor() {
            Some(i) => i,
            None => {
                if extend {
                    self.editor.move_cursor_down_extend();
                } else {
                    self.editor.move_cursor_down();
                }
                return;
            }
        };

        let len = self.layout_lines.len();
        // Treat a table as a single stop: skip down over all of its lines to the
        // first line below it.
        let cur_block = self.layout_lines[cur_idx].block_index;
        let mut target_idx = if self.block_is_table(cur_block) {
            let mut t = cur_idx + 1;
            while t < len && self.layout_lines[t].block_index == cur_block {
                t += 1;
            }
            t
        } else {
            cur_idx + 1
        };

        // Skip "phantom" target lines whose resolved caret position maps back to
        // the current line. In reveal-codes mode a block's trailing inline tags
        // (e.g. a closing `<Bold]`) can wrap onto a line of their own; that line
        // has zero document width, so its sole offset is shared with the end of
        // the previous content line. Landing on it would leave the caret visually
        // put — and, because the offset always resolves back to the content line,
        // would trap Down forever (see the ARCHITECTURE.md reveal-mode stall).
        let new_pos = loop {
            if target_idx >= len {
                // Only phantom (or no) lines remain below: already at the bottom.
                return;
            }
            let candidate = self.vertical_target_pos(target_idx);
            if self.line_index_for_pos(&candidate.path, candidate.offset) != Some(cur_idx) {
                break candidate;
            }
            target_idx += 1;
        };
        if extend {
            self.editor.extend_selection_to(new_pos);
        } else {
            self.editor.set_cursor(new_pos);
        }
    }

    /// Move cursor to the beginning of the current visual line.
    pub fn move_cursor_visual_line_start(&mut self, extend: bool, ctx: &mut dyn RenderContext) {
        self.layout(ctx);
        if self.layout_lines.is_empty() {
            if extend {
                self.editor.move_cursor_to_line_start_extend();
            } else {
                self.editor.move_cursor_to_line_start();
            }
            return;
        }

        let cursor_block = self.index_for_path(&self.editor.cursor().path);
        let line_idx = match self.current_line_index_for_cursor() {
            Some(idx) => idx,
            None => {
                if extend {
                    self.editor.move_cursor_to_line_start_extend();
                } else {
                    self.editor.move_cursor_to_line_start();
                }
                return;
            }
        };
        let line = &self.layout_lines[line_idx];
        if Some(line.block_index) != cursor_block {
            if extend {
                self.editor.move_cursor_to_line_start_extend();
            } else {
                self.editor.move_cursor_to_line_start();
            }
            return;
        }

        let new_pos = self.pos_at_index(line.block_index, line.char_start);
        if extend {
            self.editor.extend_selection_to(new_pos.clone());
        } else {
            self.editor.set_cursor(new_pos.clone());
        }
        self.record_preferred_pos(new_pos);
    }

    /// Move cursor to the end of the current visual line.
    pub fn move_cursor_visual_line_end_precise(
        &mut self,
        extend: bool,
        ctx: &mut dyn RenderContext,
    ) {
        self.layout(ctx);
        if self.layout_lines.is_empty() {
            if extend {
                self.editor.move_cursor_to_line_end_extend();
            } else {
                self.editor.move_cursor_to_line_end();
            }
            return;
        }

        let cursor_block = self.index_for_path(&self.editor.cursor().path);
        let line_idx = match self.current_line_index_for_cursor() {
            Some(idx) => idx,
            None => {
                if extend {
                    self.editor.move_cursor_to_line_end_extend();
                } else {
                    self.editor.move_cursor_to_line_end();
                }
                return;
            }
        };
        let line = &self.layout_lines[line_idx];
        if Some(line.block_index) != cursor_block {
            if extend {
                self.editor.move_cursor_to_line_end_extend();
            } else {
                self.editor.move_cursor_to_line_end();
            }
            return;
        }

        let mut target_offset = line.char_end;
        while target_offset > line.char_start
            && !self.offset_belongs_to_line(line_idx, target_offset)
        {
            target_offset -= 1;
        }

        let new_pos = self.pos_at_index(line.block_index, target_offset);
        if extend {
            self.editor.extend_selection_to(new_pos.clone());
        } else {
            // Land behind any reveal tags at the line end (e.g. a closing `<Bold]`).
            self.editor.set_cursor_after_reveal_tags(new_pos.clone());
        }
        self.record_preferred_pos(new_pos);
    }

    /// The largest displayed number in the ordered list the item at `idx` belongs to.
    /// Walks siblings at the same list level, skipping continuation paragraphs and nested
    /// content (which would otherwise break a naive contiguous-block run) and stopping at
    /// the list's boundary (a shallower level or a different list at the same level).
    fn ordered_run_max_number(
        &self,
        blocks: &[Block],
        leaves: &[tree_walk::LeafInfo],
        idx: usize,
    ) -> u64 {
        let level = leaves[idx].list_levels;
        let mut max_num = match blocks[idx].block_type {
            BlockType::ListItem { number, .. } => number.unwrap_or(1),
            _ => 1,
        };
        let visit = |j: usize, max_num: &mut u64| -> bool {
            let lj = leaves[j].list_levels;
            if lj < level {
                return false; // left this list level → stop
            }
            if lj > level {
                return true; // nested content → skip, keep scanning
            }
            match blocks[j].block_type {
                BlockType::ListItem {
                    ordered: true,
                    number,
                    ..
                } => {
                    *max_num = (*max_num).max(number.unwrap_or(1));
                    true
                }
                BlockType::ListItem { .. } => false, // a different list at this level → stop
                _ => true,                           // same-level continuation → skip
            }
        };
        for j in (idx + 1)..blocks.len() {
            if !visit(j, &mut max_num) {
                break;
            }
        }
        for j in (0..idx).rev() {
            if !visit(j, &mut max_num) {
                break;
            }
        }
        max_num
    }

    /// The marker "slot" width for the list item at `idx` (a bullet, the widest `N. ` in an
    /// ordered list, or a checkbox), used to align both the marker and the item's content.
    fn list_marker_pad_width(
        &self,
        blocks: &[Block],
        leaves: &[tree_walk::LeafInfo],
        idx: usize,
        ctx: &mut dyn RenderContext,
    ) -> i32 {
        let pf = self.theme.plain_text;
        match blocks[idx].block_type {
            BlockType::ListItem {
                checkbox: Some(_), ..
            } => {
                if self.theme.checkbox_text {
                    ctx.text_width("[ ] ", pf.font_type, pf.font_style, pf.font_size) as i32
                } else {
                    let mut box_size = (pf.font_size as i32).saturating_sub(4).max(8);
                    if box_size > self.theme.line_height {
                        box_size = self.theme.line_height;
                    }
                    let mut space =
                        ctx.text_width(" ", pf.font_type, pf.font_style, pf.font_size) as i32;
                    if space < 4 {
                        space = 4;
                    }
                    box_size + space
                }
            }
            BlockType::ListItem { ordered: true, .. } => {
                let max_num = self.ordered_run_max_number(blocks, leaves, idx);
                ctx.text_width(
                    &format!("{}. ", max_num),
                    pf.font_type,
                    pf.font_style,
                    pf.font_size,
                ) as i32
            }
            _ => ctx.text_width("• ", pf.font_type, pf.font_style, pf.font_size) as i32,
        }
    }

    /// The marker width of the list item that governs the content at `block_idx` (the item
    /// whose text this continuation paragraph / nested content should align under). Falls
    /// back to a bullet width if no governing item is found.
    fn governing_marker_pad_width(
        &self,
        blocks: &[Block],
        leaves: &[tree_walk::LeafInfo],
        block_idx: usize,
        ctx: &mut dyn RenderContext,
    ) -> i32 {
        let level = leaves[block_idx].list_levels;
        for j in (0..=block_idx).rev() {
            let lj = leaves[j].list_levels;
            if lj < level {
                break;
            }
            if lj > level {
                continue; // nested content → skip past it
            }
            if matches!(blocks[j].block_type, BlockType::ListItem { .. }) {
                return self.list_marker_pad_width(blocks, leaves, j, ctx);
            }
        }
        let pf = self.theme.plain_text;
        ctx.text_width("• ", pf.font_type, pf.font_style, pf.font_size) as i32
    }

    /// The x-position (relative to `self.x`) of each quote bar enclosing the leaf at
    /// `block_idx`, ordered outermost→innermost; empty when the leaf is not quoted.
    ///
    /// The flat `quote_depth`/`list_levels` counts drop the *order* in which quotes and
    /// lists nest, but the tree path preserves it. Walking the path, each quote bar is
    /// placed at the left edge of the region it opens — so a quote nested inside a list
    /// item's content (Quote > List > Quote) gets its bar shifted right to line up with
    /// that content, instead of hugging the outer bar. The running offset mirrors the
    /// content indent math in `layout_block` (quote levels add `quote_indent`; list
    /// levels add the same `font + step*(n-1) + marker` used for `interior_x`), so the
    /// innermost bar lands exactly one `quote_indent` left of the leaf's text.
    fn quote_bar_positions(
        &self,
        blocks: &[Block],
        leaves: &[tree_walk::LeafInfo],
        block_idx: usize,
        ctx: &mut dyn RenderContext,
    ) -> Vec<i32> {
        let info = &leaves[block_idx];
        if info.quote_depth == 0 {
            return Vec::new();
        }
        let pf = self.theme.plain_text;
        let font = pf.font_size as i32;
        let step = font.max(self.theme.list_indent);
        let marker_w = self.governing_marker_pad_width(blocks, leaves, block_idx, ctx);
        // Indent contributed by `l` enclosing list levels, matching `interior_x`.
        let list_part = |l: i32| {
            if l <= 0 {
                0
            } else {
                font + step * (l - 1) + marker_w
            }
        };

        let mut quotes = 0i32;
        let mut lists = 0i32;
        let mut bars = Vec::with_capacity(info.quote_depth);
        for seg in info.path.segments() {
            match seg {
                PathSegment::QuoteChild(_) => {
                    bars.push(
                        self.theme.padding_horizontal
                            + quotes * self.theme.quote_indent
                            + list_part(lists)
                            + self.theme.quote_bar_offset,
                    );
                    quotes += 1;
                }
                PathSegment::ListEntry { .. } | PathSegment::ChecklistItem(_) => lists += 1,
                PathSegment::Paragraph(_) => {}
            }
        }
        bars
    }

    /// Layout a single block. `blocks`/`leaves` are the full frame slices (for sibling
    /// scans such as ordered-list run detection); `block_idx` indexes them.
    /// `quote_depth`/`list_levels` come from the leaf and drive indentation independently of
    /// the (flat) block type.
    #[allow(clippy::too_many_arguments)]
    fn layout_block(
        &mut self,
        block: &Block,
        blocks: &[Block],
        leaves: &[tree_walk::LeafInfo],
        block_idx: usize,
        quote_depth: usize,
        list_levels: usize,
        y: i32,
        width: i32,
        ctx: &mut dyn RenderContext,
    ) -> i32 {
        // Indentation is driven by the leaf's tree depths, not its (flat) block type: a
        // continuation paragraph, code block, or list item nested inside a quote keeps both
        // its quote indent and its list indent even though its `BlockType` records only one.
        let quote_indent = quote_depth as i32 * self.theme.quote_indent;
        let start_x = self.theme.padding_horizontal + quote_indent;
        let width = width - quote_indent;
        let default_line_height = self.theme.line_height;

        // Content that lives inside a list but is not itself a marker line (continuation
        // paragraphs, code blocks, nested quote text) aligns with the list item's content.
        let interior_x = if list_levels > 0 {
            let pf = self.theme.plain_text;
            // Align continuation content (paragraphs, code, nested quotes) with its list
            // item's text — i.e. past the item's *actual* marker (a wide `10. ` number, a
            // bullet, a checkbox), not a hardcoded bullet width. One base em plus a
            // per-nesting-level step; the step is at least `list_indent`, so a cell backend
            // (font_size == 0) still indents nested levels while the GUI keeps its
            // one-em-per-level pixel metrics.
            let step = (pf.font_size as i32).max(self.theme.list_indent);
            let marker_w = self.governing_marker_pad_width(blocks, leaves, block_idx, ctx);
            start_x + pf.font_size as i32 + step * (list_levels as i32 - 1) + marker_w
        } else {
            start_x
        };
        let interior_width = width - (interior_x - start_x);

        match &block.block_type {
            BlockType::Paragraph => self.layout_inline_block(
                block,
                block_idx,
                y,
                interior_x,
                interior_width,
                default_line_height,
                ctx,
            ),
            BlockType::Heading { level } => {
                let header_font = match level {
                    1 => self.theme.header_level_1,
                    2 => self.theme.header_level_2,
                    _ => self.theme.header_level_3,
                };
                let height =
                    (((header_font.font_size as f32) * 1.3) as i32).max(default_line_height);
                // Add top margin for headings (unless it's the first block)
                let top_margin = if block_idx > 0 {
                    self.theme.heading_top_margin
                } else {
                    0
                };
                let line_start = self.layout_lines.len();
                let y_after = self.layout_inline_block(
                    block,
                    block_idx,
                    y + top_margin,
                    interior_x,
                    interior_width,
                    height,
                    ctx,
                );
                if *level == 1 && self.theme.center_level1_headings {
                    self.center_layout_lines(line_start, interior_width);
                }
                // When an underline rule is drawn (H2/H3), reserve its own row so
                // it sits directly under the heading text instead of borrowing a
                // row from the following margin.
                let underline_row = if self.theme.heading_underline && (*level == 2 || *level == 3)
                {
                    default_line_height
                } else {
                    0
                };
                y_after + self.theme.heading_bottom_margin + underline_row
            }
            BlockType::CodeBlock { .. } => {
                let text = block.to_plain_text();
                let lines: Vec<&str> = text.lines().collect();
                let f = self.theme.code_text;
                let mut current_y = y + self.theme.code_block_padding;
                let code_start_x = interior_x + self.theme.code_block_indent;
                let is_empty = lines.is_empty();

                // The code block is a single leaf whose plain text is the lines
                // joined by '\n'. Each visual line must carry its *cumulative*
                // byte range within that text (not 0..line_len), or vertical
                // cursor navigation can't tell the lines apart and gets stuck.
                let mut offset = 0usize;
                for line in &lines {
                    let line_width =
                        ctx.text_width(line, f.font_type, f.font_style, f.font_size) as i32;
                    let line_len = line.len();
                    let char_start = offset;
                    let char_end = offset + line_len;
                    let runs = vec![VisualRun {
                        text: (*line).to_string(),
                        x: code_start_x,
                        width: line_width,
                        font_type: f.font_type,
                        font_style: f.font_style,
                        font_size: f.font_size,
                        font_color: f.font_color,
                        background_color: f.background_color,
                        underline: false,
                        strikethrough: false,
                        block_index: block_idx,
                        char_range: (char_start, char_end),
                        inline_index: None,
                        checklist: None,
                        reveal_tag: false,
                    }];
                    let visual_char_end = self.compute_visual_char_end(&runs, char_end, false);
                    self.layout_lines.push(LayoutLine {
                        y: current_y,
                        height: default_line_height,
                        base_x: code_start_x,
                        block_index: block_idx,
                        char_start,
                        char_end,
                        visual_char_end,
                        runs,
                    });
                    current_y += default_line_height;
                    // Advance past this line's text and the '\n' that follows it.
                    offset = char_end + 1;
                }

                if is_empty {
                    self.layout_lines.push(LayoutLine {
                        y: current_y,
                        height: default_line_height,
                        base_x: code_start_x,
                        block_index: block_idx,
                        char_start: 0,
                        char_end: 0,
                        visual_char_end: 0,
                        runs: Vec::new(),
                    });
                    current_y += default_line_height;
                }

                // With fences, the top fence lives in the leading `code_block_padding`
                // row and the bottom fence needs a single trailing row; without
                // fences the GUI keeps padding above and below.
                if self.theme.code_block_fence {
                    current_y + self.theme.code_block_padding
                } else {
                    current_y + self.theme.code_block_padding * 2
                }
            }
            BlockType::BlockQuote => {
                // The quote indent is already folded into `start_x`/`interior_x`; the
                // vertical bar(s) are drawn per line in draw() from the leaf's quote depth.
                self.layout_inline_block(
                    block,
                    block_idx,
                    y + self.theme.quote_spacing,
                    interior_x,
                    interior_width,
                    default_line_height,
                    ctx,
                ) + self.theme.quote_spacing
            }
            BlockType::ListItem {
                ordered,
                number,
                checkbox,
                depth,
            } => {
                let plain_font = self.theme.plain_text;

                // Base indent (one em) before the label, plus a per-nesting-level step.
                // The step is at least `list_indent`, so a cell backend (font_size == 0)
                // still indents nested items; depth 0 keeps the original flat-list metrics.
                let step = (plain_font.font_size as i32).max(self.theme.list_indent);
                let label_left_pad = plain_font.font_size as i32 + step * (*depth as i32);

                let mut checklist_visual: Option<ChecklistVisual> = None;

                // Determine label text and padding width
                let (label_text, label_pad_width, content_start_x) = if let Some(checked) = checkbox
                {
                    if self.theme.checkbox_text {
                        // Cell backend: render the marker as bracketed text in the
                        // run itself (no drawn square), so it reads as `[✓] `/`[ ] `.
                        let marker = if *checked { "[✓] " } else { "[ ] " };
                        let w = ctx.text_width(
                            marker,
                            plain_font.font_type,
                            plain_font.font_style,
                            plain_font.font_size,
                        ) as i32;
                        let content_start_x = start_x + label_left_pad + w;
                        (marker.to_string(), w, content_start_x)
                    } else {
                        let mut marker_box_size = (plain_font.font_size as i32).saturating_sub(4);
                        if marker_box_size < 8 {
                            marker_box_size = 8;
                        }
                        if marker_box_size > default_line_height {
                            marker_box_size = default_line_height;
                        }

                        let mut space_width = ctx.text_width(
                            " ",
                            plain_font.font_type,
                            plain_font.font_style,
                            plain_font.font_size,
                        ) as i32;
                        if space_width < 4 {
                            space_width = 4;
                        }

                        let label_pad_width = marker_box_size + space_width;
                        let content_start_x = start_x + label_left_pad + label_pad_width;

                        checklist_visual = Some(ChecklistVisual {
                            checked: *checked,
                            box_size: marker_box_size,
                        });

                        (String::new(), label_pad_width, content_start_x)
                    }
                } else if *ordered {
                    // Pad all items to the width of the list's largest number so their text
                    // aligns; the run spans the whole list, skipping continuation paragraphs
                    // and nested content that would otherwise split a naive block run.
                    let max_num = self.ordered_run_max_number(blocks, leaves, block_idx);
                    let label_pad_width = ctx.text_width(
                        &format!("{}. ", max_num),
                        plain_font.font_type,
                        plain_font.font_style,
                        plain_font.font_size,
                    ) as i32;
                    let label_text = format!("{}. ", number.unwrap_or(1));
                    let content_start_x = start_x + label_left_pad + label_pad_width;

                    (label_text, label_pad_width, content_start_x)
                } else {
                    // Unordered bullet label and fixed width
                    let bullet_text = "• ".to_string();
                    let bullet_width = ctx.text_width(
                        &bullet_text,
                        plain_font.font_type,
                        plain_font.font_style,
                        plain_font.font_size,
                    ) as i32;
                    let content_start_x = start_x + label_left_pad + bullet_width;
                    (bullet_text, bullet_width, content_start_x)
                };

                // Assemble label run(s). List markers (bullet, number, `[ ]`/
                // `[✓]`) are structural, so they take the structural color — a
                // no-op for the GUI (defaults to plain text) but tinted gray by a
                // cell backend to match classic Pure's faint markers.
                let plain = self.theme.plain_text;
                let label_x = start_x + label_left_pad;
                let marker_run =
                    |text: String, x: i32, width: i32, color: u32, style: FontStyle| VisualRun {
                        text,
                        x,
                        width,
                        font_type: plain.font_type,
                        font_style: style,
                        font_size: plain.font_size,
                        font_color: color,
                        background_color: plain.background_color,
                        underline: false,
                        strikethrough: false,
                        block_index: block_idx,
                        char_range: (0, 0),
                        inline_index: None,
                        checklist: None,
                        reveal_tag: false,
                    };
                let mut runs = if self.theme.checkbox_text
                    && matches!(checkbox, Some(true))
                    && label_text == "[✓] "
                {
                    // Split the checked marker so the tick can take the checkmark
                    // color (and bold) while the brackets stay structural — the
                    // way classic Pure styled it.
                    let lb = ctx.text_width("[", plain.font_type, plain.font_style, plain.font_size)
                        as i32;
                    let ck = ctx.text_width("✓", plain.font_type, plain.font_style, plain.font_size)
                        as i32;
                    vec![
                        marker_run(
                            "[".to_string(),
                            label_x,
                            lb,
                            self.theme.structural_color,
                            plain.font_style,
                        ),
                        marker_run(
                            "✓".to_string(),
                            label_x + lb,
                            ck,
                            self.theme.checkmark_color,
                            FontStyle::Bold,
                        ),
                        marker_run(
                            "] ".to_string(),
                            label_x + lb + ck,
                            label_pad_width - lb - ck,
                            self.theme.structural_color,
                            plain.font_style,
                        ),
                    ]
                } else {
                    let mut run = marker_run(
                        label_text,
                        label_x,
                        label_pad_width,
                        self.theme.structural_color,
                        plain.font_style,
                    );
                    run.checklist = checklist_visual;
                    vec![run]
                };

                // Layout the content with proper text indentation
                let layout = self.layout_inline_content(
                    &block.content,
                    &block.block_type,
                    block_idx,
                    y,
                    content_start_x,
                    width - (content_start_x - start_x),
                    default_line_height,
                    false,
                    true,
                    ctx,
                );
                let (mut content_runs, content_ranges, content_wraps, _y_after) = (
                    layout.lines,
                    layout.line_ranges,
                    layout.line_wraps,
                    layout.y_after,
                );

                let mut current_y = y;

                // Merge bullet with first line
                if !content_runs.is_empty() && !content_runs[0].is_empty() {
                    let first_wrap = content_wraps.first().copied().unwrap_or(false);
                    let first_range = content_ranges.first().copied().unwrap_or((0, 0));
                    runs.append(&mut content_runs[0]);

                    // Calculate char range from content runs (skip bullet)
                    let char_start = runs
                        .iter()
                        .skip(1)
                        .map(|r| r.char_range.0)
                        .min()
                        .unwrap_or(first_range.0);
                    let char_end = runs
                        .iter()
                        .skip(1)
                        .map(|r| r.char_range.1)
                        .max()
                        .unwrap_or(first_range.1);

                    let visual_char_end = self.compute_visual_char_end(&runs, char_end, first_wrap);
                    self.layout_lines.push(LayoutLine {
                        y: current_y,
                        height: default_line_height,
                        base_x: content_start_x,
                        block_index: block_idx,
                        char_start,
                        char_end,
                        visual_char_end,
                        runs,
                    });
                    current_y += default_line_height;

                    // Add remaining lines
                    for (idx, line_runs) in content_runs.iter().enumerate().skip(1) {
                        let (char_start, char_end) =
                            content_ranges.get(idx).copied().unwrap_or((0, 0));
                        let wrapped = content_wraps.get(idx).copied().unwrap_or(false);
                        let visual_char_end =
                            self.compute_visual_char_end(line_runs, char_end, wrapped);

                        self.layout_lines.push(LayoutLine {
                            y: current_y,
                            height: default_line_height,
                            base_x: content_start_x,
                            block_index: block_idx,
                            char_start,
                            char_end,
                            visual_char_end,
                            runs: line_runs.clone(),
                        });
                        current_y += default_line_height;
                    }
                } else {
                    // Just bullet
                    self.layout_lines.push(LayoutLine {
                        y: current_y,
                        height: default_line_height,
                        base_x: content_start_x,
                        block_index: block_idx,
                        char_start: 0,
                        char_end: 0,
                        visual_char_end: 0,
                        runs,
                    });
                    current_y += default_line_height;
                }

                current_y + self.theme.list_item_spacing
            }
            BlockType::Table { rows } => self.layout_table(block_idx, rows, y, start_x, width, ctx),
        }
    }

    /// Lay out a read-only table: compute column widths, wrap each cell, and
    /// emit one `LayoutLine` per physical text line plus a `TableLayout` holding
    /// the grid geometry for `draw`. Modeled on the tdoc CLI's bordered grid.
    fn layout_table(
        &mut self,
        block_idx: usize,
        rows: &[TableRow],
        y: i32,
        start_x: i32,
        width: i32,
        ctx: &mut dyn RenderContext,
    ) -> i32 {
        const BORDER: i32 = 1;
        let pad_h = self.theme.table_cell_padding_h; // horizontal padding inside each cell
        let pad_v = self.theme.table_cell_padding_v; // vertical padding inside each row
        let line_height = self.theme.line_height;

        let column_count = rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
        if rows.is_empty() || column_count == 0 {
            return y;
        }

        // Natural (single-line) content width per column, measured with the
        // cell's font (header cells are bold and thus a touch wider).
        let plain = self.theme.plain_text;
        let mut natural = vec![0i32; column_count];
        for row in rows {
            for (col, cell) in row.cells.iter().enumerate() {
                let style = if cell.is_header {
                    FontStyle::Bold
                } else {
                    plain.font_style
                };
                let text = cell.to_plain_text().replace('\n', " ");
                let w = ctx.text_width(&text, plain.font_type, style, plain.font_size) as i32;
                natural[col] = natural[col].max(w);
            }
        }

        // Fit the columns into the available width. Each column needs PAD_H on
        // both sides; vertical borders add BORDER around/between columns.
        let cols = column_count as i32;
        let structural = (cols + 1) * BORDER + cols * 2 * pad_h;
        let min_col = (plain.font_size as i32).max(8); // keep ~1 char visible
        let budget = (width - structural).max(cols * min_col);
        let total_natural: i32 = natural.iter().sum();

        let mut col_width = vec![0i32; column_count];
        if total_natural <= budget || total_natural == 0 {
            col_width.copy_from_slice(&natural);
        } else {
            // Shrink proportionally to fit, never below min_col.
            for (c, &nat) in natural.iter().enumerate() {
                let scaled = (nat as i64 * budget as i64 / total_natural as i64) as i32;
                col_width[c] = scaled.max(min_col);
            }
        }

        // Vertical grid-line positions: a border sits at each col_x; the cell
        // interior between consecutive lines is PAD_H + content + PAD_H.
        let mut col_x = vec![0i32; column_count + 1];
        col_x[0] = start_x;
        for c in 0..column_count {
            col_x[c + 1] = col_x[c] + BORDER + pad_h + col_width[c] + pad_h;
        }

        let mut row_y = Vec::with_capacity(rows.len() + 1);
        let mut current_y = y;

        for row in rows {
            row_y.push(current_y);

            // Wrap each cell to its column width, capturing its visual lines.
            let mut cell_lines: Vec<Vec<Vec<VisualRun>>> = Vec::with_capacity(column_count);
            for col in 0..column_count {
                let lines = match row.cells.get(col) {
                    Some(cell) if !cell.content.is_empty() => {
                        let content = if cell.is_header {
                            bolden_inline(&cell.content)
                        } else {
                            cell.content.clone()
                        };
                        let cell_x = col_x[col] + BORDER + pad_h;
                        self.layout_inline_content(
                            &content,
                            &BlockType::Paragraph,
                            block_idx,
                            0,
                            cell_x,
                            col_width[col],
                            line_height,
                            // Force-break tokens too wide for the column so cell
                            // content can't bleed into the next column.
                            true,
                            // Tables are read-only; never reveal codes in cells.
                            false,
                            ctx,
                        )
                        .lines
                    }
                    _ => Vec::new(),
                };
                cell_lines.push(lines);
            }

            let row_line_count = cell_lines.iter().map(Vec::len).max().unwrap_or(0).max(1);

            // One LayoutLine per physical text line, gathering the matching
            // wrapped line from every cell so the shared draw loop renders them.
            let content_top = current_y + BORDER + pad_v;
            for li in 0..row_line_count {
                let mut runs: Vec<VisualRun> = Vec::new();
                for cell in &cell_lines {
                    if let Some(line) = cell.get(li) {
                        runs.extend(line.iter().cloned());
                    }
                }
                self.layout_lines.push(LayoutLine {
                    y: content_top + li as i32 * line_height,
                    height: line_height,
                    base_x: start_x,
                    block_index: block_idx,
                    char_start: 0,
                    char_end: 0,
                    visual_char_end: 0,
                    runs,
                });
            }

            current_y = content_top + row_line_count as i32 * line_height + pad_v;
        }

        row_y.push(current_y); // bottom border

        self.table_layouts.push(TableLayout {
            block_index: block_idx,
            col_x,
            row_y,
        });

        current_y + BORDER + 10
    }

    /// Layout an inline block (paragraph, heading, etc.)
    #[allow(clippy::too_many_arguments)]
    fn layout_inline_block(
        &mut self,
        block: &Block,
        block_idx: usize,
        y: i32,
        start_x: i32,
        width: i32,
        line_height: i32,
        ctx: &mut dyn RenderContext,
    ) -> i32 {
        let layout = self.layout_inline_content(
            &block.content,
            &block.block_type,
            block_idx,
            y,
            start_x,
            width,
            line_height,
            false,
            true,
            ctx,
        );
        let (lines, line_ranges, line_wraps, _y_after) = (
            layout.lines,
            layout.line_ranges,
            layout.line_wraps,
            layout.y_after,
        );

        let mut current_y = y;

        if lines.is_empty() {
            // Empty block - create an empty layout line for cursor positioning
            self.layout_lines.push(LayoutLine {
                y: current_y,
                height: line_height,
                base_x: start_x,
                block_index: block_idx,
                char_start: 0,
                char_end: 0,
                visual_char_end: 0,
                runs: Vec::new(),
            });
            current_y += line_height;
        } else {
            for (line_runs, ((char_start, char_end), wrapped)) in lines
                .into_iter()
                .zip(line_ranges.into_iter().zip(line_wraps))
            {
                let base_x = line_runs
                    .iter()
                    .filter(|r| !(r.char_range.0 == r.char_range.1 && r.inline_index.is_none()))
                    .map(|r| r.x)
                    .min()
                    .unwrap_or(start_x);

                let visual_char_end = self.compute_visual_char_end(&line_runs, char_end, wrapped);

                self.layout_lines.push(LayoutLine {
                    y: current_y,
                    height: line_height,
                    base_x,
                    block_index: block_idx,
                    char_start,
                    char_end,
                    visual_char_end,
                    runs: line_runs,
                });
                current_y += line_height;
            }
        }

        current_y + self.theme.paragraph_spacing
    }

    /// Horizontally center the layout lines `from..` within a column of `width`
    /// (the runs already begin at the column's left edge). Used for centered
    /// headings; shifts each line's runs (and `base_x`) by half its slack so
    /// cursor hit-testing follows the visible text.
    fn center_layout_lines(&mut self, from: usize, width: i32) {
        for line in self.layout_lines[from..].iter_mut() {
            // Visual span of the line: leftmost run start to rightmost run end,
            // ignoring empty placeholder runs.
            let mut min_x = i32::MAX;
            let mut max_x = i32::MIN;
            for run in &line.runs {
                if run.char_range.0 == run.char_range.1 && run.inline_index.is_none() {
                    continue;
                }
                min_x = min_x.min(run.x);
                max_x = max_x.max(run.x + run.width);
            }
            if min_x == i32::MAX {
                continue; // nothing to center
            }
            let span = max_x - min_x;
            let offset = ((width - span) / 2).max(0);
            if offset == 0 {
                continue;
            }
            for run in &mut line.runs {
                run.x += offset;
            }
            line.base_x += offset;
        }
    }

    fn resolve_text_run_style(
        &self,
        base_font: FontSettings,
        text_style: &TextStyle,
    ) -> ResolvedRunStyle {
        let settings = if text_style.code {
            self.theme.code_text
        } else {
            base_font
        };

        let base_is_bold = matches!(settings.font_style, FontStyle::Bold | FontStyle::BoldItalic);
        let base_is_italic = matches!(
            settings.font_style,
            FontStyle::Italic | FontStyle::BoldItalic
        );

        let final_bold = text_style.bold || base_is_bold;
        let final_italic = text_style.italic || base_is_italic;

        let font_style = match (final_bold, final_italic) {
            (true, true) => FontStyle::BoldItalic,
            (true, false) => FontStyle::Bold,
            (false, true) => FontStyle::Italic,
            (false, false) => FontStyle::Regular,
        };

        let highlight = text_style.highlight;
        let mut background_color = settings.background_color;
        if highlight {
            background_color = Some(self.theme.highlight_color);
        }

        ResolvedRunStyle {
            font_type: settings.font_type,
            font_style,
            font_size: settings.font_size,
            font_color: settings.font_color,
            background_color,
            underline: text_style.underline,
            strikethrough: text_style.strikethrough,
        }
    }

    fn resolve_link_run_style(&self, base_font: FontSettings) -> ResolvedRunStyle {
        let mut style = self.resolve_text_run_style(base_font, &TextStyle::plain());
        style.font_color = self.theme.link_color;
        style.underline = true;
        style
    }

    /// Resolved style of a reveal-codes tag: the theme's tag colors, plain weight.
    fn reveal_tag_style(&self) -> ResolvedRunStyle {
        let base = self.theme.plain_text;
        ResolvedRunStyle {
            font_type: base.font_type,
            font_style: FontStyle::Regular,
            font_size: base.font_size,
            font_color: self.theme.reveal_tag_fg,
            background_color: Some(self.theme.reveal_tag_bg),
            underline: false,
            strikethrough: false,
        }
    }

    /// Layout inline content with word wrapping
    /// Returns (lines of runs, final_y)
    #[allow(clippy::too_many_arguments)]
    fn layout_inline_content(
        &mut self,
        content: &[InlineContent],
        block_type: &BlockType,
        block_idx: usize,
        y: i32,
        start_x: i32,
        width: i32,
        line_height: i32,
        break_long_words: bool,
        allow_reveal: bool,
        ctx: &mut dyn RenderContext,
    ) -> InlineContentLayout {
        let mut lines: Vec<Vec<VisualRun>> = Vec::new();
        let mut line_wraps: Vec<bool> = Vec::new();
        let mut line_ranges: Vec<(usize, usize)> = Vec::new();
        let mut current_line: Vec<VisualRun> = Vec::new();
        let mut current_x = start_x;
        let mut current_y = y;
        let mut char_offset = 0;

        let base_font = match block_type {
            BlockType::Heading { level } => match level {
                1 => self.theme.header_level_1,
                2 => self.theme.header_level_2,
                _ => self.theme.header_level_3,
            },
            BlockType::BlockQuote => self.theme.quote_text,
            BlockType::CodeBlock { .. } => self.theme.code_text,
            _ => self.theme.plain_text,
        };

        // Reveal codes: surface inline-style boundaries as `[Bold>` / `<Bold]`
        // tags interleaved with the text. The reconciler tracks the open tags so
        // a style stays open across an inner style's span (classic nesting). Off
        // unless the caller permits it (not for read-only table cells) and the
        // editor is in reveal mode.
        let reveal = allow_reveal && self.editor.reveal_codes();
        let reveal_style = self.reveal_tag_style();
        let mut reconciler = RevealReconciler::new();

        let mut pending_empty_line = false;

        for (inline_idx, item) in content.iter().enumerate() {
            if reveal && let Some(target) = item_reveal_styles(item) {
                let (closes, opens) = reconciler.reconcile(&target);
                emit_reveal_tags(
                    ctx,
                    &mut lines,
                    &mut line_ranges,
                    &mut line_wraps,
                    &mut current_line,
                    &mut current_x,
                    &mut current_y,
                    start_x,
                    width,
                    line_height,
                    char_offset,
                    block_idx,
                    reveal_style,
                    &closes,
                    &opens,
                );
            }
            match item {
                InlineContent::Text(run) => {
                    pending_empty_line = false;
                    let style = self.resolve_text_run_style(base_font, &run.style);
                    layout_styled_text(
                        ctx,
                        &mut lines,
                        &mut line_ranges,
                        &mut line_wraps,
                        &mut current_line,
                        &mut current_x,
                        &mut current_y,
                        start_x,
                        width,
                        line_height,
                        break_long_words,
                        self.theme.wrap_defer_trailing_space,
                        &run.text,
                        char_offset,
                        inline_idx,
                        block_idx,
                        style,
                    );
                    char_offset += run.text.len();
                }
                InlineContent::Link {
                    link: _,
                    content: link_content,
                } => {
                    pending_empty_line = false;
                    if self.theme.link_uses_content_style {
                        // Lay out each inner span with its own weight/slant and
                        // paint the link color + underline over it — per-span,
                        // the way classic Pure merged the link style onto each
                        // span. Wrapping matches plain text (word by word).
                        for inner in link_content {
                            let inner_text = inner.to_plain_text();
                            let mut style = match inner {
                                InlineContent::Text(run) => {
                                    self.resolve_text_run_style(base_font, &run.style)
                                }
                                _ => self.resolve_text_run_style(base_font, &TextStyle::plain()),
                            };
                            style.font_color = self.theme.link_color;
                            style.underline = true;
                            // Reveal codes: show the inner runs' style tags nested
                            // inside the link's `[Link>`…`<Link]`. The reconciler
                            // already holds `Link`; reconcile each inner run to
                            // `Link` + its own styles so e.g. a bold link shows
                            // `[Link>[Bold>…<Bold]<Link]`.
                            if reveal {
                                let mut target = vec![RevealStyle::Link];
                                if let InlineContent::Text(run) = inner {
                                    target.extend(reveal_styles(&run.style));
                                }
                                let (closes, opens) = reconciler.reconcile(&target);
                                emit_reveal_tags(
                                    ctx,
                                    &mut lines,
                                    &mut line_ranges,
                                    &mut line_wraps,
                                    &mut current_line,
                                    &mut current_x,
                                    &mut current_y,
                                    start_x,
                                    width,
                                    line_height,
                                    char_offset,
                                    block_idx,
                                    reveal_style,
                                    &closes,
                                    &opens,
                                );
                            }
                            layout_styled_text(
                                ctx,
                                &mut lines,
                                &mut line_ranges,
                                &mut line_wraps,
                                &mut current_line,
                                &mut current_x,
                                &mut current_y,
                                start_x,
                                width,
                                line_height,
                                break_long_words,
                                self.theme.wrap_defer_trailing_space,
                                &inner_text,
                                char_offset,
                                inline_idx,
                                block_idx,
                                style,
                            );
                            char_offset += inner_text.len();
                        }
                    } else {
                        // Flat link style (pixel backends): one run, plain weight.
                        let style = self.resolve_link_run_style(base_font);
                        let text = link_content
                            .iter()
                            .map(|c| c.to_plain_text())
                            .collect::<String>();

                        push_token_wrapped(
                            ctx,
                            &mut lines,
                            &mut line_ranges,
                            &mut line_wraps,
                            &mut current_line,
                            &mut current_x,
                            &mut current_y,
                            start_x,
                            width,
                            line_height,
                            break_long_words,
                            self.theme.wrap_defer_trailing_space,
                            &text,
                            char_offset,
                            inline_idx,
                            block_idx,
                            style,
                        );
                        char_offset += text.len();
                    }
                }
                InlineContent::HardBreak => {
                    push_line(
                        &mut lines,
                        &mut line_ranges,
                        &mut line_wraps,
                        &mut current_line,
                        char_offset,
                        false,
                    );
                    current_x = start_x;
                    current_y += line_height;
                    char_offset += 1;
                    pending_empty_line = true;
                }
            }
        }

        // Close any reveal tags still open after the last run.
        if reveal {
            let closes = reconciler.finish();
            emit_reveal_tags(
                ctx,
                &mut lines,
                &mut line_ranges,
                &mut line_wraps,
                &mut current_line,
                &mut current_x,
                &mut current_y,
                start_x,
                width,
                line_height,
                char_offset,
                block_idx,
                reveal_style,
                &closes,
                &[],
            );
        }

        let is_empty = lines.is_empty();

        if !current_line.is_empty() {
            push_line(
                &mut lines,
                &mut line_ranges,
                &mut line_wraps,
                &mut current_line,
                char_offset,
                false,
            );
        } else if pending_empty_line {
            // Preserve the trailing hard break by materializing an empty visual line.
            push_line(
                &mut lines,
                &mut line_ranges,
                &mut line_wraps,
                &mut current_line,
                char_offset,
                false,
            );
        }

        InlineContentLayout {
            lines,
            line_ranges,
            line_wraps,
            y_after: if is_empty { y } else { current_y },
        }
    }

    /// Check if a visual run intersects with the current selection
    /// Returns None if no selection, or Some((start_offset, end_offset)) relative to the run
    fn get_run_selection_range(&self, run: &VisualRun) -> Option<(usize, usize)> {
        let (a, b) = self.editor.selection()?;
        // Normalize selection so start <= end (document order).
        let (sel_start, sel_end) = if a <= b { (a, b) } else { (b, a) };
        let sel_start_idx = self.index_for_path(&sel_start.path)?;
        let sel_end_idx = self.index_for_path(&sel_end.path)?;

        // Check if this run's block is within the selection
        if run.block_index < sel_start_idx || run.block_index > sel_end_idx {
            return None;
        }

        // Determine the selection range within this run
        let run_start = run.char_range.0;
        let run_end = run.char_range.1;

        let sel_start_offset = if run.block_index == sel_start_idx {
            sel_start.offset
        } else {
            0
        };

        let sel_end_offset = if run.block_index == sel_end_idx {
            sel_end.offset
        } else {
            usize::MAX
        };

        // Check if run intersects with selection
        if run_end <= sel_start_offset || run_start >= sel_end_offset {
            return None;
        }

        // Calculate the intersection
        let start_in_run = sel_start_offset
            .saturating_sub(run_start)
            .min(run_end - run_start);
        let end_in_run = sel_end_offset
            .saturating_sub(run_start)
            .min(run_end - run_start);

        Some((start_in_run, end_in_run))
    }

    /// Draw the grid lines and header-cell backgrounds for every table block.
    /// Cell text itself is drawn by the normal `LayoutLine` run loop; this only
    /// paints the structural decoration around it.
    /// Draw a rule under each level-2/3 heading (`=` for H2, `-` for H3) when
    /// `theme.heading_underline` is set. The rule lands in the blank row directly
    /// below the heading — purely decorative, so it never enters the cursor model.
    fn draw_heading_underlines(&self, ctx: &mut dyn RenderContext) {
        if !self.theme.heading_underline {
            return;
        }
        let viewport_top = self.scroll_offset;
        let viewport_bottom = self.scroll_offset + self.h;
        for i in 0..self.layout_lines.len() {
            let line = &self.layout_lines[i];
            let ch = match self
                .layout_blocks
                .get(line.block_index)
                .map(|b| &b.block_type)
            {
                Some(BlockType::Heading { level: 2 }) => '=',
                Some(BlockType::Heading { level: 3 }) => '-',
                _ => continue,
            };
            if !self.is_last_visual_line_in_block(i) {
                continue;
            }
            // Span of the heading text on this line.
            let mut min_x = i32::MAX;
            let mut max_x = i32::MIN;
            for run in &line.runs {
                if run.char_range.0 == run.char_range.1 && run.inline_index.is_none() {
                    continue;
                }
                min_x = min_x.min(run.x);
                max_x = max_x.max(run.x + run.width);
            }
            if min_x == i32::MAX {
                continue;
            }
            let rule_y = line.y + line.height;
            if rule_y < viewport_top || rule_y > viewport_bottom {
                continue;
            }
            let count = (max_x - min_x).max(1) as usize;
            let rule: String = ch.to_string().repeat(count);
            let f = self.theme.plain_text;
            ctx.set_font(f.font_type, FontStyle::Regular, f.font_size);
            ctx.set_color(self.theme.structural_color);
            ctx.draw_text(&rule, self.x + min_x, self.y + rule_y - self.scroll_offset);
        }
    }

    /// Draw classic-Pure code fences: a full-content-width rule above the first
    /// and below the last line of every code block. The rule rows live in the
    /// block's `code_block_padding`, so they neither count as content lines nor
    /// collide with the code text.
    fn draw_code_fences(&self, ctx: &mut dyn RenderContext) {
        if !self.theme.code_block_fence {
            return;
        }
        let viewport_top = self.scroll_offset;
        let viewport_bottom = self.scroll_offset + self.h;
        let content_left = self.theme.padding_horizontal;
        let content_right = self.w - self.theme.padding_horizontal;
        let f = self.theme.code_text;
        // Fence width is measured in cells via the code font's space width.
        let cell = (ctx.text_width("-", f.font_type, f.font_style, f.font_size) as i32).max(1);
        let count = ((content_right - content_left) / cell).max(1) as usize;
        let rule: String = "-".repeat(count);

        let mut block = usize::MAX;
        let mut first_y = 0;
        let mut last_y = 0;
        let flush = |display: &Self, top: i32, bottom: i32, ctx: &mut dyn RenderContext| {
            for rule_y in [top, bottom] {
                if rule_y < viewport_top || rule_y > viewport_bottom {
                    continue;
                }
                ctx.set_font(f.font_type, FontStyle::Regular, f.font_size);
                ctx.set_color(display.theme.structural_color);
                ctx.draw_text(
                    &rule,
                    display.x + content_left,
                    display.y + rule_y - display.scroll_offset,
                );
            }
        };
        for line in &self.layout_lines {
            let is_code = matches!(
                self.layout_blocks
                    .get(line.block_index)
                    .map(|b| &b.block_type),
                Some(BlockType::CodeBlock { .. })
            );
            if !is_code {
                continue;
            }
            if line.block_index != block {
                if block != usize::MAX {
                    flush(
                        self,
                        first_y - self.theme.line_height,
                        last_y + self.theme.line_height,
                        ctx,
                    );
                }
                block = line.block_index;
                first_y = line.y;
            }
            last_y = line.y;
        }
        if block != usize::MAX {
            flush(
                self,
                first_y - self.theme.line_height,
                last_y + self.theme.line_height,
                ctx,
            );
        }
    }

    fn draw_tables(&self, ctx: &mut dyn RenderContext) {
        let viewport_top = self.scroll_offset;
        let viewport_bottom = self.scroll_offset + self.h;
        let blocks = &self.layout_blocks;

        for table in &self.table_layouts {
            let (top, bottom) = match (table.row_y.first(), table.row_y.last()) {
                (Some(&t), Some(&b)) => (t, b),
                _ => continue,
            };
            // Skip tables entirely outside the viewport.
            if bottom < viewport_top || top > viewport_bottom {
                continue;
            }
            let left = *table.col_x.first().unwrap();
            let right = *table.col_x.last().unwrap();

            let sx = self.x;
            let sy = self.y - self.scroll_offset;

            // Header-cell backgrounds, drawn first so text and grid sit on top.
            if let Some(BlockType::Table { rows }) =
                blocks.get(table.block_index).map(|b| &b.block_type)
            {
                ctx.set_color(self.theme.table_header_background);
                for (r, row) in rows.iter().enumerate() {
                    if r + 1 >= table.row_y.len() {
                        break;
                    }
                    let cell_top = sy + table.row_y[r];
                    let cell_h = table.row_y[r + 1] - table.row_y[r];
                    for (c, cell) in row.cells.iter().enumerate() {
                        if cell.is_header && c + 1 < table.col_x.len() {
                            let cx = sx + table.col_x[c];
                            let cw = table.col_x[c + 1] - table.col_x[c];
                            ctx.draw_rect_filled(cx, cell_top, cw, cell_h);
                        }
                    }
                }
            }

            // Grid lines.
            ctx.set_color(self.theme.table_border_color);
            for &x in &table.col_x {
                ctx.draw_line(sx + x, sy + top, sx + x, sy + bottom);
            }
            for &yv in &table.row_y {
                ctx.draw_line(sx + left, sy + yv, sx + right, sy + yv);
            }
        }
    }

    /// Draw the vertical quote bars for every visible line.
    ///
    /// A bar is drawn from a line's top down to the *next* line's top whenever that
    /// next line carries the same bar (same nesting level, same column) — bridging the
    /// inter-paragraph / inter-block gaps so a multi-paragraph quote (or a quote broken
    /// up by list items and nested content) renders as one unbroken rule instead of a
    /// dashed stack of segments. When the next line drops the level, the bar stops at
    /// the current line's bottom. Columns come from `layout_leaf_bars`, which already
    /// shifts inner bars right to account for interleaved list nesting.
    fn draw_quote_bars(
        &self,
        ctx: &mut dyn RenderContext,
        viewport_top: i32,
        viewport_bottom: i32,
    ) {
        ctx.set_color(self.theme.quote_bar_color);
        let lh = self.theme.line_height.max(1);
        for (i, line) in self.layout_lines.iter().enumerate() {
            let bars = match self.layout_leaf_bars.get(line.block_index) {
                Some(b) if !b.is_empty() => b,
                _ => continue,
            };
            let next = self.layout_lines.get(i + 1);
            let next_bars = next.and_then(|nl| self.layout_leaf_bars.get(nl.block_index));
            for (level, &bx) in bars.iter().enumerate() {
                // Bridge the gap to the next line only when it shares this exact bar.
                let bottom_content = match (next, next_bars) {
                    (Some(nl), Some(nb)) if nb.get(level) == Some(&bx) => nl.y,
                    _ => line.y + line.height,
                };
                if bottom_content < viewport_top || line.y > viewport_bottom {
                    continue;
                }
                let x = self.x + bx;
                let top_y = self.y + line.y - self.scroll_offset;
                let bottom_y = self.y + bottom_content - self.scroll_offset;
                if self.theme.quote_bar_as_text {
                    let mut ry = top_y;
                    while ry < bottom_y {
                        ctx.draw_text("|", x, ry);
                        ry += lh;
                    }
                } else {
                    ctx.draw_line(x, top_y, x, bottom_y);
                }
            }
        }
    }

    /// Draw the widget
    pub fn draw(&mut self, ctx: &mut dyn RenderContext) {
        self.layout(ctx);

        // Draw background
        ctx.set_color(self.theme.background_color);
        ctx.draw_rect_filled(self.x, self.y, self.w, self.h);

        // Set up clipping
        ctx.push_clip(self.x, self.y, self.w, self.h);

        // Draw visible lines
        let viewport_top = self.scroll_offset;
        let viewport_bottom = self.scroll_offset + self.h;

        // Table grid lines and header fills sit behind the cell text.
        self.draw_tables(ctx);

        // Heading rules (`===`/`---` under H2/H3) and code fences when the
        // backend asks for them.
        self.draw_heading_underlines(ctx);
        self.draw_code_fences(ctx);

        // Vertical quote bars run behind the text so a quote's paragraphs, list
        // items and nested content read as one continuous rule.
        self.draw_quote_bars(ctx, viewport_top, viewport_bottom);

        for line in &self.layout_lines {
            if line.y + line.height < viewport_top || line.y > viewport_bottom {
                continue;
            }

            // Resolve the hovered link (if any) to this frame's leaf index for comparison.
            let hovered_idx = self
                .hovered_link
                .as_ref()
                .and_then(|(path, inline)| self.index_for_path(path).map(|idx| (idx, *inline)));

            for run in &line.runs {
                // Check if this run is part of a hovered link
                let is_hovered = hovered_idx.is_some_and(|(idx, inline)| {
                    run.block_index == idx && run.inline_index == Some(inline)
                });

                let descent = ctx.text_descent(run.font_type, run.font_style, run.font_size);
                ctx.set_font(run.font_type, run.font_style, run.font_size);
                ctx.set_color(run.font_color);

                let line_top = self.y + line.y - self.scroll_offset;
                let draw_x = self.x + run.x;

                if let Some(checklist) = &run.checklist {
                    let mut box_size = checklist.box_size;
                    if box_size > line.height {
                        box_size = line.height;
                    }
                    if box_size <= 0 {
                        continue;
                    }

                    let box_y = line_top + (line.height - box_size) / 2 + descent / 2;
                    ctx.draw_checkbox(draw_x, box_y, box_size, checklist.checked);

                    continue;
                }

                let draw_y = line_top + run.font_size as i32;

                // Draw inline highlight background for styles that specify a bgcolor
                // (e.g., text highlight). Draw this first so selection can paint over it.
                {
                    let text_width =
                        ctx.text_width(&run.text, run.font_type, run.font_style, run.font_size)
                            as i32;
                    if let Some(col) = run.background_color {
                        ctx.set_color(col);
                        ctx.draw_rect_filled(draw_x, line_top, text_width, line.height);
                        ctx.set_color(run.font_color); // Restore text color for text drawing
                    }
                }

                // Draw background for hover (if link is hovered)
                // Draw this BEFORE selection so selection remains visible on top
                if is_hovered {
                    let text_width =
                        ctx.text_width(&run.text, run.font_type, run.font_style, run.font_size)
                            as i32;
                    ctx.set_color(self.theme.link_hover_background);
                    ctx.draw_rect_filled(draw_x, line_top, text_width, line.height);
                    ctx.set_color(run.font_color); // Restore text color
                }

                // Draw search highlight (if run contains a search match)
                // Draw BEFORE selection so selection takes priority
                if let Some((search_start, search_end, is_current)) =
                    self.get_run_search_highlight(run)
                    && search_end > search_start
                {
                    let text_before = if search_start < run.text.len() {
                        &run.text[..search_start]
                    } else {
                        &run.text
                    };
                    let text_match = if search_end <= run.text.len() {
                        &run.text[search_start..search_end]
                    } else if search_start < run.text.len() {
                        &run.text[search_start..]
                    } else {
                        ""
                    };

                    let before_width =
                        ctx.text_width(text_before, run.font_type, run.font_style, run.font_size)
                            as i32;
                    let match_width =
                        ctx.text_width(text_match, run.font_type, run.font_style, run.font_size)
                            as i32;

                    // Use different color for current match vs other matches
                    let highlight_color = if is_current {
                        self.theme.search_current_highlight_color
                    } else {
                        self.theme.search_highlight_color
                    };

                    ctx.set_color(highlight_color);
                    ctx.draw_rect_filled(draw_x + before_width, line_top, match_width, line.height);
                    ctx.set_color(run.font_color); // Restore text color
                }

                // Draw selection highlight (if run is selected)
                // Draw AFTER hover so the selection rectangle is on top
                if let Some((sel_start, sel_end)) = self.get_run_selection_range(run)
                    && sel_end > sel_start
                {
                    // Measure the text before and within selection
                    let text_before = if sel_start < run.text.len() {
                        &run.text[..sel_start]
                    } else {
                        &run.text
                    };
                    let text_selected = if sel_end <= run.text.len() {
                        &run.text[sel_start..sel_end]
                    } else if sel_start < run.text.len() {
                        &run.text[sel_start..]
                    } else {
                        ""
                    };

                    let before_width =
                        ctx.text_width(text_before, run.font_type, run.font_style, run.font_size)
                            as i32;
                    let sel_width =
                        ctx.text_width(text_selected, run.font_type, run.font_style, run.font_size)
                            as i32;

                    ctx.set_color(self.theme.selection_color);
                    ctx.draw_rect_filled(draw_x + before_width, line_top, sel_width, line.height);
                    ctx.set_color(run.font_color); // Restore text color
                }

                ctx.set_underline(run.underline);
                ctx.set_strikethrough(run.strikethrough);
                ctx.draw_text(&run.text, draw_x, draw_y);
                ctx.set_underline(false);
                ctx.set_strikethrough(false);

                let text_width =
                    ctx.text_width(&run.text, run.font_type, run.font_style, run.font_size) as i32;

                // Pixel backends draw decorations as separate lines; cell backends
                // fold them into the glyph attributes above instead.
                if self.theme.text_decoration_lines {
                    if run.underline {
                        ctx.draw_line(draw_x, draw_y + 2, draw_x + text_width, draw_y + 2);
                    }
                    if run.strikethrough {
                        // Line through the middle of the text (~half the font size).
                        let descent =
                            ctx.text_descent(run.font_type, run.font_style, run.font_size);
                        let strike_y = draw_y - (run.font_size as i32) / 2 + descent / 2 + 1;
                        ctx.set_color(0xaaaaaaff);
                        ctx.draw_rect_filled(draw_x, strike_y, text_width, 1);
                    }
                }
            }
        }

        // Draw cursor (only when widget has keyboard focus)
        if self.cursor_visible
            && ctx.has_focus()
            && self.blink_on
            && let Some((cx, cy, ch)) = self.get_cursor_visual_position(ctx)
        {
            let screen_y = self.y + cy - self.scroll_offset;
            let screen_x = self.x + cx;

            if screen_y >= self.y && screen_y < self.y + self.h {
                // At an inline-style boundary the caret leans toward the side whose
                // style newly typed text will take, so one logical offset reads as
                // two visually distinct caret positions even with reveal codes off.
                // Suppressed when a selection is active (the lean is about where
                // typing lands, which doesn't apply to a selection), under reveal
                // codes (which draws the tags themselves), and on a cell backend
                // that can't render the lean (folded into `affinity_active`). The
                // backend decides how to draw the lean; the default draws head and
                // foot ticks.
                let lean = if !self.reveal_codes()
                    && self.editor.selection().is_none()
                    && self.editor.affinity_active()
                    && self.editor.cursor_at_style_boundary()
                {
                    match self.editor.cursor_affinity() {
                        Affinity::Left => CaretLean::Left,
                        Affinity::Right => CaretLean::Right,
                    }
                } else {
                    CaretLean::None
                };
                ctx.set_color(self.theme.cursor_color);
                ctx.draw_caret(screen_x, screen_y, ch, lean);
            }
        }

        ctx.pop_clip();
    }

    /// The cursor's on-screen position in widget coordinates, scroll-adjusted:
    /// `(x, y)` where `y` is already offset by the scroll position. Returns
    /// `None` if the cursor isn't on a laid-out line or is scrolled out of view.
    ///
    /// Intended for frontends that drive a hardware caret (e.g. a terminal's
    /// real cursor) instead of the engine-drawn one — pair with
    /// [`Self::set_cursor_visible(false)`](Self::set_cursor_visible) so the two
    /// don't both render. Call after [`draw`](Self::draw) (or
    /// [`ensure_cursor_visible`](Self::ensure_cursor_visible)) so the layout is
    /// current.
    pub fn cursor_screen_position(&self, ctx: &mut dyn RenderContext) -> Option<(i32, i32)> {
        let (cx, cy, _h) = self.get_cursor_visual_position(ctx)?;
        let screen_x = self.x + cx;
        let screen_y = self.y + cy - self.scroll_offset;
        if screen_y < self.y || screen_y >= self.y + self.h {
            return None;
        }
        Some((screen_x, screen_y))
    }

    /// The cursor's content-space `y` (top of its visual line, before scroll is
    /// applied) and the line height, or `None` when there is no resolvable
    /// cursor. Unlike [`cursor_screen_position`](Self::cursor_screen_position)
    /// this is defined even when the cursor is scrolled out of view, so callers
    /// can measure how far the cursor moves across visual rows (e.g. paging by a
    /// fixed number of rows, counting the blank gaps between blocks).
    pub fn cursor_content_y(&self, ctx: &mut dyn RenderContext) -> Option<(i32, i32)> {
        let (_cx, cy, ch) = self.get_cursor_visual_position(ctx)?;
        Some((cy, ch))
    }

    /// 1-based column of the cursor, measured from the start of its visual
    /// line's text. Unlike a column derived from the raw screen x, this stays
    /// natural when the line is centered or indented (the offset is relative to
    /// the line's leftmost content run, not the page origin).
    pub fn cursor_column(&self, ctx: &mut dyn RenderContext) -> Option<usize> {
        let (cx, _cy, _h) = self.get_cursor_visual_position(ctx)?;
        let cursor = self.editor.cursor();
        let cur_idx = self.index_for_path(&cursor.path);
        // In reveal-codes mode the inline tags inflate the visual x, so report a
        // character column relative to the line's text start instead — classic
        // Pure counted the document characters, not the on-screen tag glyphs.
        if self.reveal_codes() {
            for (idx, line) in self.layout_lines.iter().enumerate() {
                if Some(line.block_index) != cur_idx {
                    continue;
                }
                if !self.offset_belongs_to_line(idx, cursor.offset) {
                    continue;
                }
                return Some(cursor.offset.saturating_sub(line.char_start) + 1);
            }
            return None;
        }
        for (idx, line) in self.layout_lines.iter().enumerate() {
            if Some(line.block_index) != cur_idx {
                continue;
            }
            if !self.offset_belongs_to_line(idx, cursor.offset) {
                continue;
            }
            let left = line
                .runs
                .iter()
                .filter(|r| !(r.char_range.0 == r.char_range.1 && r.inline_index.is_none()))
                .map(|r| r.x)
                .min()
                .unwrap_or(line.base_x);
            return Some(((cx - left).max(0) + 1) as usize);
        }
        None
    }

    /// Layout-line accounting for a status bar that reports "content lines" the
    /// way classic Pure did. Every laid-out visual line is a content row (block
    /// spacing is empty space, decorations like underlines/fences are overlays),
    /// so `total_lines` is just the layout-line count. `cursor_line_ordinal` is
    /// the zero-based index of the cursor's layout line among all lines, and
    /// `cursor_root_paragraph` is the index of the top-level `Document.paragraphs`
    /// entry the cursor sits in (needed to add the right number of inter-block
    /// margins). The margin arithmetic itself lives in the caller.
    pub fn content_line_metrics(&self) -> ContentLineMetrics {
        let cursor = self.editor.cursor();
        let cur_idx = self.index_for_path(&cursor.path);
        let mut cursor_line_ordinal = None;
        let mut cursor_root_paragraph = None;
        for (idx, line) in self.layout_lines.iter().enumerate() {
            if Some(line.block_index) != cur_idx {
                continue;
            }
            if !self.offset_belongs_to_line(idx, cursor.offset) {
                continue;
            }
            cursor_line_ordinal = Some(idx);
            cursor_root_paragraph = self
                .layout_leaves
                .get(line.block_index)
                .and_then(|info| info.path.segments().first())
                .map(|seg| match seg {
                    PathSegment::Paragraph(p) => *p,
                    _ => 0,
                });
            break;
        }
        // A table emits LayoutLines only for its text rows; its horizontal grid
        // lines (top/separator/bottom — `row_y.len()` of them) are drawn from the
        // TableLayout and aren't LayoutLines. Classic Pure counted those border
        // rows as content lines, so fold them in: into the total, and into the
        // cursor's ordinal for any table that sits entirely above the cursor.
        let table_border_lines: usize = self.table_layouts.iter().map(|t| t.row_y.len()).sum();
        let cursor_line_ordinal = cursor_line_ordinal.map(|idx| {
            let borders_above: usize = self
                .table_layouts
                .iter()
                .filter(|t| cur_idx.is_some_and(|c| t.block_index < c))
                .map(|t| t.row_y.len())
                .sum();
            idx + borders_above
        });

        ContentLineMetrics {
            total_lines: self.layout_lines.len() + table_border_lines,
            cursor_line_ordinal,
            cursor_root_paragraph,
        }
    }

    /// Get visual position of cursor (x, y, height) relative to widget
    fn get_cursor_visual_position(&self, ctx: &mut dyn RenderContext) -> Option<(i32, i32, i32)> {
        let cursor = self.editor.cursor();
        let cur_idx = self.index_for_path(&cursor.path);

        // Find the layout line containing the cursor
        for (idx, line) in self.layout_lines.iter().enumerate() {
            if Some(line.block_index) != cur_idx {
                continue;
            }
            if !self.offset_belongs_to_line(idx, cursor.offset) {
                continue;
            }

            let mut x = line.base_x;

            for run in &line.runs {
                // Skip non-content runs (like list bullets and reveal tags, with
                // an empty char_range and no inline index).
                if run.char_range.0 == run.char_range.1 && run.inline_index.is_none() {
                    continue;
                }

                if cursor.offset >= run.char_range.0 && cursor.offset <= run.char_range.1 {
                    // Cursor is in this run - measure actual text width
                    let offset_in_run = cursor.offset - run.char_range.0;
                    let (font, fstyle, size) = (run.font_type, run.font_style, run.font_size);

                    // Measure the text up to the cursor position
                    let text_before_cursor = if offset_in_run < run.text.len() {
                        &run.text[..offset_in_run]
                    } else {
                        &run.text
                    };

                    let width_before =
                        ctx.text_width(text_before_cursor, font, fstyle, size) as i32;
                    x = run.x + width_before;
                    break;
                }

                if cursor.offset > run.char_range.1 {
                    // Cursor is after this run - measure full run width
                    let (font, fstyle, size) = (run.font_type, run.font_style, run.font_size);
                    x = run.x + ctx.text_width(&run.text, font, fstyle, size) as i32;
                }
            }

            // Reveal codes: place the caret relative to the inline-style tags
            // rendered at this offset. They lay out consecutively, so anchor the
            // caret before the first one (the "before all tags" stop — important
            // when the paragraph *starts* with a tag, where the matched text run
            // already sits past it) and advance past the first `reveal_stop` of
            // them. With no tags here this leaves the text-based x untouched.
            if self.reveal_codes() {
                let stop = self.editor.cursor_reveal_stop();
                let mut anchored = false;
                let mut passed = 0usize;
                for run in &line.runs {
                    if !(run.reveal_tag && run.char_range.0 == cursor.offset) {
                        continue;
                    }
                    if !anchored {
                        x = run.x;
                        anchored = true;
                    }
                    if passed >= stop {
                        break;
                    }
                    x = run.x
                        + ctx.text_width(&run.text, run.font_type, run.font_style, run.font_size)
                            as i32;
                    passed += 1;
                }
            }

            return Some((x, line.y, line.height));
        }

        // Default: top-left
        if let Some(first_line) = self.layout_lines.first() {
            Some((first_line.base_x, first_line.y, first_line.height))
        } else {
            Some((
                self.theme.padding_horizontal,
                self.theme.padding_vertical,
                self.theme.line_height,
            ))
        }
    }

    /// Determine if a point (widget coordinates) hits a checklist marker
    pub fn checklist_marker_hit(&self, x: i32, y: i32) -> Option<TreePath> {
        let adjusted_y = y + self.scroll_offset;

        for line in &self.layout_lines {
            if adjusted_y < line.y || adjusted_y > line.y + line.height {
                continue;
            }

            for run in &line.runs {
                if let Some(checklist) = &run.checklist {
                    if checklist.box_size <= 0 {
                        continue;
                    }

                    let marker_start_x = run.x;
                    let marker_end_x = run.x + checklist.box_size;
                    // Allow a small tolerance around the marker for easier clicking
                    let tolerance = 2;
                    if x >= marker_start_x - tolerance
                        && x <= marker_end_x + tolerance
                        && let Some(block) = self.layout_blocks.get(line.block_index)
                        && matches!(
                            block.block_type,
                            BlockType::ListItem {
                                checkbox: Some(_),
                                ..
                            }
                        )
                    {
                        return Some(self.path_for_index(line.block_index));
                    }
                }
            }
        }

        None
    }

    /// Convert x,y screen coordinates to document position
    pub fn xy_to_position(&self, x: i32, y: i32) -> DocumentPosition {
        let adjusted_y = y + self.scroll_offset;

        if self.layout_lines.is_empty() {
            return DocumentPosition::start();
        }

        // Helper to compute offset within a specific line based on x
        fn offset_in_line(line: &LayoutLine, x: i32) -> usize {
            let mut offset = line.char_start;
            for run in &line.runs {
                let run_end_x = run.x + run.width;
                if x >= run.x && x < run_end_x {
                    let click_offset_in_run = x - run.x;
                    let chars_in_run = run.char_range.1 - run.char_range.0;
                    if run.width > 0 && chars_in_run > 0 {
                        let char_pos = ((click_offset_in_run * chars_in_run as i32) / run.width)
                            .clamp(0, chars_in_run as i32 - 1)
                            as usize;
                        return run.char_range.0 + char_pos;
                    } else {
                        return run.char_range.0;
                    }
                }
                if x >= run_end_x {
                    offset = run.char_range.1;
                }
            }
            offset
        }

        // First try: direct hit on a line
        for line in &self.layout_lines {
            if adjusted_y >= line.y && adjusted_y < line.y + line.height {
                let offset = offset_in_line(line, x);
                return self.pos_at_index(line.block_index, offset);
            }
        }

        // No line directly under the cursor. Choose the nearest line vertically.
        // Find the previous line (the last line with y <= adjusted_y)
        let mut prev_idx: Option<usize> = None;
        for (i, line) in self.layout_lines.iter().enumerate() {
            if line.y <= adjusted_y {
                prev_idx = Some(i);
            } else {
                break;
            }
        }

        // Find the next line (first with y > adjusted_y)
        let next_idx = self
            .layout_lines
            .iter()
            .enumerate()
            .find(|(_, line)| line.y > adjusted_y)
            .map(|(i, _)| i);

        let target_idx = match (prev_idx, next_idx) {
            (Some(p), Some(n)) => {
                // Distance to bottom of previous vs top of next
                let prev_dist = adjusted_y - (self.layout_lines[p].y + self.layout_lines[p].height);
                let next_dist = self.layout_lines[n].y - adjusted_y;
                if prev_dist <= next_dist { p } else { n }
            }
            (Some(p), None) => p,
            (None, Some(n)) => n,
            (None, None) => 0,
        };

        let line = &self.layout_lines[target_idx];
        let offset = offset_in_line(line, x);
        self.pos_at_index(line.block_index, offset)
    }

    /// Set cursor visibility
    pub fn set_cursor_visible(&mut self, visible: bool) {
        self.cursor_visible = visible;
    }

    /// Get cursor visibility
    pub fn cursor_visible(&self) -> bool {
        self.cursor_visible
    }

    /// Update blink state based on elapsed ms. Returns true if visual state changed.
    pub fn tick(&mut self, ms_since_start: u64) -> bool {
        self.last_tick_ms = ms_since_start;
        // Compute a simple square-wave blink: 500ms on, 500ms off, measured
        // from the current blink anchor (reset whenever the cursor moves).
        let half_period = (self.blink_period_ms / 2).max(1);
        let elapsed = ms_since_start.saturating_sub(self.blink_anchor_ms);
        let new_on = (elapsed / half_period).is_multiple_of(2);
        if new_on != self.blink_on {
            self.blink_on = new_on;
            return true;
        }
        false
    }

    /// Restart the blink cycle so the caret is shown immediately and stays on
    /// for a full half-period. Call this whenever the cursor moves so it's
    /// always visible at its new position, regardless of the current blink
    /// phase (which might otherwise have the caret hidden right now).
    pub fn reset_blink(&mut self) {
        self.blink_anchor_ms = self.last_tick_ms;
        self.blink_on = true;
    }

    /// Find link at given widget coordinates (relative to widget, not screen)
    /// Returns ((block_index, inline_index), destination) if a link is found
    pub fn find_link_at(&self, x: i32, y: i32) -> Option<((TreePath, usize), String)> {
        let adjusted_y = y + self.scroll_offset;
        let adjusted_x = x;

        // Find the line at this y position
        for line in &self.layout_lines {
            if adjusted_y >= line.y && adjusted_y < line.y + line.height {
                // Find the run at this x position
                for run in &line.runs {
                    if adjusted_x >= run.x && adjusted_x < run.x + run.width {
                        // Check if this run has an inline_index pointing to a link
                        if let Some(inline_idx) = run.inline_index
                            && let Some(block) = self.layout_blocks.get(run.block_index)
                            && inline_idx < block.content.len()
                            && let InlineContent::Link { link, .. } = &block.content[inline_idx]
                        {
                            return Some((
                                (self.path_for_index(run.block_index), inline_idx),
                                link.destination.clone(),
                            ));
                        }
                    }
                }
            }
        }
        None
    }

    /// Set hovered link (for hover highlighting)
    pub fn set_hovered_link(&mut self, link: Option<(TreePath, usize)>) {
        if self.hovered_link != link {
            self.hovered_link = link;
            // Don't invalidate layout, just trigger redraw
        }
    }

    /// Get hovered link
    pub fn hovered_link(&self) -> Option<(TreePath, usize)> {
        self.hovered_link.clone()
    }

    /// Find a link at or adjacent to the current cursor position.
    ///
    /// Treats the cursor as "inside" the link when its offset lies within the
    /// link's text range, and also when it is exactly at the start (directly
    /// before) or exactly at the end (directly after) of the link.
    ///
    /// Returns ((block_index, inline_index), destination) if a link is found.
    /// Plain-text label (the text the link wraps) of the inline link at or
    /// adjacent to the cursor — for pre-filling an edit dialog's text field.
    /// `None` when the cursor is not on a link.
    pub fn link_label_near_cursor(&self) -> Option<String> {
        let cursor = self.editor.cursor();
        let cur_idx = self.index_for_path(&cursor.path)?;
        let block = self.layout_blocks.get(cur_idx)?;
        let mut pos = 0usize;
        for item in &block.content {
            let len = item.text_len();
            if matches!(item, InlineContent::Link { .. })
                && cursor.offset >= pos
                && cursor.offset <= pos + len
            {
                return Some(item.to_plain_text());
            }
            pos += len;
        }
        None
    }

    pub fn find_link_near_cursor(&self) -> Option<((TreePath, usize), String)> {
        let cursor = self.editor.cursor();
        let cur_idx = self.index_for_path(&cursor.path)?;
        let block = self.layout_blocks.get(cur_idx)?;
        let mut pos = 0usize;

        for (inline_idx, item) in block.content.iter().enumerate() {
            let len = item.text_len();
            if let InlineContent::Link { link, .. } = item {
                let start = pos;
                let end = pos + len; // end is exclusive for text, but we allow equality for adjacency

                // Cursor is within, or exactly at start/end (adjacent)
                if cursor.offset >= start && cursor.offset <= end {
                    return Some(((cursor.path.clone(), inline_idx), link.destination.clone()));
                }
            }
            pos += len;
        }

        None
    }

    // ==================== Search Methods ====================

    /// Perform a case-insensitive search for the given term.
    /// Updates the internal search state with all matches found.
    /// Returns the number of matches found.
    pub fn search(&mut self, term: &str) -> usize {
        self.search_term = term.to_string();
        self.search_matches.clear();
        self.search_current_index = None;

        if term.is_empty() {
            return 0;
        }

        let term_lower = term.to_lowercase();
        // `block_index` is the leaf's index in document order, matching layout frames.
        let leaves = tree_walk::enumerate_leaves(self.editor.document());
        let mut matches = Vec::new();
        for (block_idx, info) in leaves.iter().enumerate() {
            let text = tree_walk::leaf_plain_text(self.editor.document(), &info.path);
            let text_lower = text.to_lowercase();

            // Find all occurrences in this block
            let mut search_start = 0;
            while let Some(pos) = text_lower[search_start..].find(&term_lower) {
                let start_offset = search_start + pos;
                let end_offset = start_offset + term.len();
                matches.push(SearchMatch {
                    block_index: block_idx,
                    start_offset,
                    end_offset,
                });
                search_start = start_offset + 1; // Move past this match to find overlapping matches
            }
        }
        self.search_matches = matches;

        // If we found matches, set current index to 0
        if !self.search_matches.is_empty() {
            self.search_current_index = Some(0);
        }

        self.search_matches.len()
    }

    /// Clear the search state
    pub fn clear_search(&mut self) {
        self.search_term.clear();
        self.search_matches.clear();
        self.search_current_index = None;
    }

    /// Get the current search term
    pub fn search_term(&self) -> &str {
        &self.search_term
    }

    /// Get all search matches
    pub fn search_matches(&self) -> &[SearchMatch] {
        &self.search_matches
    }

    /// Get the current match index (0-based)
    pub fn search_current_index(&self) -> Option<usize> {
        self.search_current_index
    }

    /// Get the current search match
    pub fn current_search_match(&self) -> Option<&SearchMatch> {
        self.search_current_index
            .and_then(|idx| self.search_matches.get(idx))
    }

    /// Move to the next search match. Returns true if moved.
    pub fn next_match(&mut self) -> bool {
        if self.search_matches.is_empty() {
            return false;
        }

        let next_idx = match self.search_current_index {
            Some(idx) => (idx + 1) % self.search_matches.len(),
            None => 0,
        };

        self.search_current_index = Some(next_idx);
        true
    }

    /// Move to the previous search match. Returns true if moved.
    pub fn prev_match(&mut self) -> bool {
        if self.search_matches.is_empty() {
            return false;
        }

        let prev_idx = match self.search_current_index {
            Some(idx) => {
                if idx == 0 {
                    self.search_matches.len() - 1
                } else {
                    idx - 1
                }
            }
            None => self.search_matches.len() - 1,
        };

        self.search_current_index = Some(prev_idx);
        true
    }

    /// Scroll to make the current search match visible.
    /// Should be called after search() or next_match()/prev_match().
    pub fn scroll_to_current_match(&mut self, ctx: &mut dyn RenderContext) {
        let Some(current_match) = self.current_search_match().cloned() else {
            return;
        };

        // Ensure layout is up to date
        self.layout(ctx);

        // Find the layout line containing this match
        for line in &self.layout_lines {
            if line.block_index == current_match.block_index
                && current_match.start_offset >= line.char_start
                && current_match.start_offset < line.char_end
            {
                // Calculate the target scroll position to center the match in the viewport
                let line_center_y = line.y + line.height / 2;
                let viewport_center = self.h / 2;
                let target_scroll = (line_center_y - viewport_center).max(0);

                // Clamp to valid scroll range
                let max_scroll = (self.content_height() - self.h).max(0);
                self.scroll_offset = target_scroll.min(max_scroll);
                return;
            }
        }
    }

    /// Check if a visual run intersects with any search match.
    /// Returns Some((start_offset_in_run, end_offset_in_run, is_current)) if there's an intersection.
    fn get_run_search_highlight(&self, run: &VisualRun) -> Option<(usize, usize, bool)> {
        if self.search_matches.is_empty() {
            return None;
        }

        let current_idx = self.search_current_index;

        for (idx, search_match) in self.search_matches.iter().enumerate() {
            // Check if this match is in the same block as the run
            if search_match.block_index != run.block_index {
                continue;
            }

            // Check if match intersects with run's char range
            let run_start = run.char_range.0;
            let run_end = run.char_range.1;
            let match_start = search_match.start_offset;
            let match_end = search_match.end_offset;

            // Check for intersection
            if match_end <= run_start || match_start >= run_end {
                continue;
            }

            // Calculate intersection within the run
            let start_in_run = match_start
                .saturating_sub(run_start)
                .min(run_end - run_start);
            let end_in_run = match_end.saturating_sub(run_start).min(run_end - run_start);

            if end_in_run > start_in_run {
                let is_current = current_idx == Some(idx);
                return Some((start_in_run, end_in_run, is_current));
            }
        }

        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::inline_convert::inline_to_spans;
    use crate::render_context::{FontStyle, FontType};
    use crate::structured_document::{
        Block, BlockType, InlineContent, TableCell, TableRow, TextRun,
    };
    use crate::tree_path::{DocumentPosition, TreePath};
    use crate::tree_walk;
    use tdoc::Document;
    use tdoc::paragraph::{
        ChecklistItem, Paragraph, TableCell as TdocTableCell, TableRow as TdocTableRow,
    };

    /// Build a single tdoc paragraph from a transient `Block` (test convenience).
    fn block_to_paragraph(block: &Block) -> Paragraph {
        let spans = inline_to_spans(&block.content);
        match &block.block_type {
            BlockType::Paragraph => Paragraph::new_text().with_content(spans),
            BlockType::Heading { level } => match level {
                1 => Paragraph::new_header1().with_content(spans),
                2 => Paragraph::new_header2().with_content(spans),
                _ => Paragraph::new_header3().with_content(spans),
            },
            BlockType::CodeBlock { .. } => Paragraph::new_code_block().with_content(spans),
            BlockType::BlockQuote => Paragraph::new_quote()
                .with_children(vec![Paragraph::new_text().with_content(spans)]),
            BlockType::ListItem {
                ordered, checkbox, ..
            } => {
                if let Some(checked) = checkbox {
                    let item = ChecklistItem::new(*checked).with_content(spans);
                    Paragraph::new_checklist().with_checklist_items(vec![item])
                } else if *ordered {
                    Paragraph::new_ordered_list()
                        .with_entries(vec![vec![Paragraph::new_text().with_content(spans)]])
                } else {
                    Paragraph::new_unordered_list()
                        .with_entries(vec![vec![Paragraph::new_text().with_content(spans)]])
                }
            }
            BlockType::Table { rows } => Paragraph::Table {
                rows: rows
                    .iter()
                    .map(|r| TdocTableRow {
                        cells: r
                            .cells
                            .iter()
                            .map(|c| TdocTableCell {
                                is_header: c.is_header,
                                content: inline_to_spans(&c.content),
                            })
                            .collect(),
                    })
                    .collect(),
            },
        }
    }

    /// Byte length of the first leaf's plain text.
    fn leaf0_len(display: &Renderer) -> usize {
        tree_walk::leaf_text_len(display.editor().document(), &TreePath::root(0))
    }

    #[derive(Default)]
    struct TestRenderContext {
        focus: bool,
        active: bool,
        /// Emulate a character-cell backend: report no caret-affinity support, so
        /// the engine should collapse the two boundary stops into one. Default
        /// `false` keeps the pixel-backend behavior the other tests expect.
        cell_backend: bool,
        /// Every filled rect drawn this pass, as `(x, y, w, h)` — lets tests
        /// observe the caret bar and its foot tick.
        rects: Vec<(i32, i32, i32, i32)>,
    }

    impl TestRenderContext {
        fn new_with_focus() -> Self {
            Self {
                focus: true,
                active: true,
                ..Default::default()
            }
        }
    }

    impl RenderContext for TestRenderContext {
        fn set_color(&mut self, _color: u32) {}

        fn set_font(&mut self, _font: FontType, _style: FontStyle, _size: u8) {}

        fn draw_text(&mut self, _text: &str, _x: i32, _y: i32) {}

        fn draw_rect_filled(&mut self, x: i32, y: i32, w: i32, h: i32) {
            self.rects.push((x, y, w, h));
        }

        fn draw_line(&mut self, _x1: i32, _y1: i32, _x2: i32, _y2: i32) {}

        fn supports_caret_affinity(&self) -> bool {
            !self.cell_backend
        }

        fn text_width(&mut self, text: &str, _font: FontType, _style: FontStyle, size: u8) -> f64 {
            // Simplistic width model: proportional to character count and font size
            text.chars().count() as f64 * (size as f64) * 0.6
        }

        fn text_height(&self, _font: FontType, _style: FontStyle, size: u8) -> i32 {
            size as i32
        }

        fn text_descent(&self, _font: FontType, _style: FontStyle, size: u8) -> i32 {
            ((size as f32) * 0.2).round() as i32
        }

        fn push_clip(&mut self, _x: i32, _y: i32, _w: i32, _h: i32) {}

        fn pop_clip(&mut self) {}

        fn color_average(&self, c1: u32, _c2: u32, _weight: f32) -> u32 {
            c1
        }

        fn color_contrast(&self, fg: u32, _bg: u32) -> u32 {
            fg
        }

        fn color_inactive(&self, c: u32) -> u32 {
            c
        }

        fn has_focus(&self) -> bool {
            self.focus
        }

        fn is_active(&self) -> bool {
            self.active
        }
    }

    fn make_display_with_block(block: Block) -> Renderer {
        make_display_with_blocks(vec![block])
    }

    fn make_display_with_blocks(blocks: Vec<Block>) -> Renderer {
        let mut display = Renderer::new(0, 0, 400, 300);
        let doc = Document::new().with_paragraphs(blocks.iter().map(block_to_paragraph).collect());
        {
            let editor = display.editor_mut();
            editor.set_document(doc);
            editor.set_cursor(DocumentPosition::new(0, 0));
        }
        display
    }

    #[test]
    fn default_caret_draws_direction_ticks_at_style_boundary() {
        // "Hello " (plain) + "World!" (bold); style boundary at byte offset 6.
        // The default draw_caret marks the lean with 6x2 head and foot ticks.
        let doc = crate::markdown_converter::markdown_to_document("Hello **World!**");
        let mut display = Renderer::new(0, 0, 400, 300);
        display.editor_mut().set_document(doc);

        // Draw, then return the direction-tick rects (w == 6, h == 2).
        fn ticks(display: &mut Renderer) -> Vec<(i32, i32, i32, i32)> {
            let mut ctx = TestRenderContext::new_with_focus();
            display.draw(&mut ctx);
            ctx.rects
                .iter()
                .copied()
                .filter(|(_, _, w, h)| *w == 6 && *h == 2)
                .collect()
        }

        // Mid-plain-text: not a boundary, so no ticks.
        display
            .editor_mut()
            .set_cursor(DocumentPosition::at(TreePath::root(0), 3));
        assert!(ticks(&mut display).is_empty());

        // At the boundary with (default) Left affinity: a head and a foot tick,
        // aligned in x and at distinct y's, both left of the caret.
        display
            .editor_mut()
            .set_cursor(DocumentPosition::at(TreePath::root(0), 6));
        let left = ticks(&mut display);
        assert_eq!(
            left.len(),
            2,
            "expected head and foot ticks at the boundary"
        );
        let left_x = left[0].0;
        assert!(left.iter().all(|t| t.0 == left_x), "ticks share an x");
        assert_ne!(
            left[0].1, left[1].1,
            "head and foot ticks sit at different y"
        );

        // Flip to Right affinity at the same offset: the ticks move to the right.
        display.editor_mut().move_cursor_right();
        assert_eq!(display.editor().cursor_affinity(), Affinity::Right);
        let right = ticks(&mut display);
        assert_eq!(right.len(), 2);
        assert!(
            right[0].0 > left_x,
            "Right-affinity ticks must sit right of the Left-affinity ticks"
        );
    }

    #[test]
    fn selection_suppresses_direction_ticks() {
        // Cursor resting on the style boundary, but with a selection active.
        let doc = crate::markdown_converter::markdown_to_document("Hello **World!**");
        let mut display = Renderer::new(0, 0, 400, 300);
        display.editor_mut().set_document(doc);
        {
            let editor = display.editor_mut();
            editor.set_cursor(DocumentPosition::at(TreePath::root(0), 5));
            editor.move_cursor_right_extend(); // active end lands on the boundary (6)
        }
        assert!(display.editor().selection().is_some());
        assert!(display.editor().cursor_at_style_boundary());

        let mut ctx = TestRenderContext::new_with_focus();
        display.draw(&mut ctx);
        let ticks = ctx
            .rects
            .iter()
            .filter(|(_, _, w, h)| *w == 6 && *h == 2)
            .count();
        assert_eq!(ticks, 0, "no direction ticks while a selection is active");
    }

    #[test]
    fn cell_backend_collapses_affinity() {
        // A backend that can't render the lean (a character cell) must get the
        // classic single-caret model: no extra stop, no lean, even at a boundary.
        let doc = crate::markdown_converter::markdown_to_document("Hello **World!**");
        let mut display = Renderer::new(0, 0, 400, 300);
        display.editor_mut().set_document(doc);
        // Land on the boundary and try to flip to Right affinity — before any draw
        // has synced the backend capability, so this momentarily takes.
        {
            let editor = display.editor_mut();
            editor.set_cursor(DocumentPosition::at(TreePath::root(0), 6));
            editor.move_cursor_right();
            assert_eq!(editor.cursor_affinity(), Affinity::Right);
        }

        // Draw through a cell backend: the layout pass syncs the capability, which
        // resets the affinity and makes it inert.
        let mut ctx = TestRenderContext {
            focus: true,
            active: true,
            cell_backend: true,
            ..Default::default()
        };
        display.draw(&mut ctx);

        assert!(!display.editor().affinity_active(), "affinity is inert");
        assert_eq!(display.editor().cursor_affinity(), Affinity::Left);
        let ticks = ctx
            .rects
            .iter()
            .filter(|(_, _, w, h)| *w == 6 && *h == 2)
            .count();
        assert_eq!(ticks, 0, "cell backend draws no direction ticks");

        // And Right stepping no longer pauses at the boundary: from just before it,
        // one press crosses the grapheme instead of flipping affinity in place.
        display
            .editor_mut()
            .set_cursor(DocumentPosition::at(TreePath::root(0), 6));
        display.editor_mut().move_cursor_right();
        assert!(
            !display.editor().cursor_at_style_boundary(),
            "no extra affinity stop on a cell backend"
        );
    }

    fn table_block(rows: usize) -> Block {
        let rows = (0..rows)
            .map(|r| {
                TableRow::new(vec![TableCell::new(
                    false,
                    vec![InlineContent::Text(TextRun::plain(format!("r{r}")))],
                )])
            })
            .collect();
        Block::table(rows)
    }

    #[test]
    fn quote_nested_content_keeps_quote_and_list_indent() {
        // A quote containing a numbered list, a nested bullet, and a continuation
        // paragraph: every line must carry the quote indent/bar *and* its list indent.
        let md = "> Plain in quote\n>\n> 1. Numbered in quote\n>\n>    - Bullet in quote\n>\n>      Continuation in bullet";
        let doc = crate::markdown_converter::markdown_to_document(md);
        let mut display = Renderer::new(0, 0, 600, 400);
        display.editor_mut().set_document(doc);
        let mut ctx = TestRenderContext::new_with_focus();
        display.layout(&mut ctx);

        // Text is split into word runs; match each line by a unique first word.
        let x_of = |needle: &str| -> i32 {
            display
                .layout_lines
                .iter()
                .flat_map(|l| &l.runs)
                .find(|r| r.text.starts_with(needle))
                .map(|r| r.x)
                .unwrap_or_else(|| panic!("no run starting with {needle:?}"))
        };

        let pad = display.theme.padding_horizontal;
        let quote_indent = display.theme.quote_indent;
        // The plain quote paragraph sits at exactly the quote indent.
        assert_eq!(x_of("Plain"), pad + quote_indent);
        // Deeper list nesting indents further; each is still past the quote indent.
        assert!(x_of("Numbered") > pad + quote_indent);
        assert!(x_of("Bullet") > x_of("Numbered"));
        // The continuation paragraph aligns with its bullet item's content (not far left).
        assert_eq!(x_of("Continuation"), x_of("Bullet"));

        // Every leaf in the quote reports quote_depth 1, so each gets a vertical bar.
        for info in &display.layout_leaves {
            assert_eq!(
                info.quote_depth, 1,
                "leaf {:?} lost its quote depth",
                info.path
            );
        }
    }

    #[test]
    fn nested_quote_bar_aligns_with_list_content() {
        // Quote > unordered list > quote. The inner quote's bar must sit at the list
        // item's content column (not hug the outer bar), and the inner quote's text
        // must sit one quote-step past that inner bar.
        let md = "> Outer\n>\n> - Item\n>\n>   Cont\n>\n>   > Inner";
        let doc = crate::markdown_converter::markdown_to_document(md);
        let mut display = Renderer::new(0, 0, 600, 400);
        display.editor_mut().set_document(doc);
        let mut ctx = TestRenderContext::new_with_focus();
        display.layout(&mut ctx);

        let bars_of = |needle: &str| -> Vec<i32> {
            let idx = display
                .layout_lines
                .iter()
                .find(|l| l.runs.iter().any(|r| r.text.starts_with(needle)))
                .map(|l| l.block_index)
                .unwrap_or_else(|| panic!("no line starting with {needle:?}"));
            display.layout_leaf_bars[idx].clone()
        };
        let x_of = |needle: &str| -> i32 {
            display
                .layout_lines
                .iter()
                .flat_map(|l| &l.runs)
                .find(|r| r.text.starts_with(needle))
                .map(|r| r.x)
                .unwrap_or_else(|| panic!("no run starting with {needle:?}"))
        };

        let pad = display.theme.padding_horizontal;
        let qi = display.theme.quote_indent;
        let off = display.theme.quote_bar_offset;

        // Single-quote lines get one bar, flush at the outer quote column.
        assert_eq!(bars_of("Outer"), vec![pad + off]);
        assert_eq!(bars_of("Cont"), vec![pad + off]);

        // The nested-quote line gets two bars: the outer flush left, the inner shifted
        // right to sit at the list item's content column ("Cont").
        let inner_bars = bars_of("Inner");
        assert_eq!(inner_bars.len(), 2, "nested quote should have two bars");
        assert_eq!(inner_bars[0], pad + off, "outer bar stays flush left");
        assert_eq!(
            inner_bars[1],
            x_of("Cont") + off,
            "inner bar aligns with the list item's content column"
        );
        // The inner quote's own text sits one quote-step past the list content.
        assert_eq!(x_of("Inner"), x_of("Cont") + qi);
    }

    #[test]
    fn nested_list_item_is_more_indented() {
        // A list item nested under another should lay out further to the right.
        let inner = Paragraph::new_unordered_list().with_entries(vec![vec![
            Paragraph::new_text().with_content(vec![tdoc::inline::Span::new_text("bbb")]),
        ]]);
        let outer = Paragraph::new_unordered_list().with_entries(vec![vec![
            Paragraph::new_text().with_content(vec![tdoc::inline::Span::new_text("aaa")]),
            inner,
        ]]);
        let doc = Document::new().with_paragraphs(vec![outer]);
        let mut display = Renderer::new(0, 0, 400, 300);
        display.editor_mut().set_document(doc);
        let mut ctx = TestRenderContext::new_with_focus();
        display.layout(&mut ctx);

        let x_of = |needle: &str| -> i32 {
            display
                .layout_lines
                .iter()
                .flat_map(|l| &l.runs)
                .find(|r| r.text.contains(needle))
                .map(|r| r.x)
                .unwrap_or_else(|| panic!("no run containing {needle:?}"))
        };
        assert!(
            x_of("bbb") > x_of("aaa"),
            "nested item ({}) should be more indented than its parent ({})",
            x_of("bbb"),
            x_of("aaa")
        );
    }

    #[test]
    fn vertical_nav_treats_table_as_single_stop() {
        // A multi-row table produces several layout lines; Up/Down must treat
        // the whole table as one stop instead of stepping through each line.
        let mut ctx = TestRenderContext::new_with_focus();
        let mut display = make_display_with_blocks(vec![
            Block::paragraph().with_plain_text("one"),
            table_block(3),
            Block::paragraph().with_plain_text("two"),
        ]);
        // Force layout so the table contributes multiple layout lines.
        display.draw(&mut ctx);
        assert_eq!(display.editor().cursor().path, TreePath::root(0));

        // Down: land on the table once.
        display.move_cursor_visual_down(false, &mut ctx);
        assert_eq!(display.editor().cursor().path, TreePath::root(1));

        // Down again: skip the entire table to the paragraph below it.
        display.move_cursor_visual_down(false, &mut ctx);
        assert_eq!(display.editor().cursor().path, TreePath::root(2));

        // Up: back onto the table once.
        display.move_cursor_visual_up(false, &mut ctx);
        assert_eq!(display.editor().cursor().path, TreePath::root(1));

        // Up again: skip the entire table to the first paragraph.
        display.move_cursor_visual_up(false, &mut ctx);
        assert_eq!(display.editor().cursor().path, TreePath::root(0));
    }

    #[test]
    fn reveal_codes_wrapped_inline_tag_does_not_trap_down() {
        // Regression: in reveal-codes mode a block's trailing inline tag (e.g. a
        // closing `<Bold]`) can wrap onto a line of its own. That line carries
        // zero document width, so its only offset coincides with the end of the
        // preceding content line — and `move_cursor_visual_down` used to keep
        // landing on it, freezing the caret partway through the document. (Found
        // via the ARCHITECTURE.md reveal-mode cursor-down benchmark, which stalled
        // at a bold list item ~94 paragraphs before the end.)
        let md = "- **bold words that should wrap across several lines in this one list item**\n\nAFTER\n";
        let doc = crate::markdown_converter::markdown_to_document(md);

        // The wrap window is only a few pixels wide and depends on font metrics,
        // so search narrow→wide for a width at which the trailing reveal tag wraps
        // onto its own zero-width line (the phantom) rather than hardcoding it.
        let is_phantom = |d: &Renderer| {
            d.layout_lines.iter().any(|l| {
                l.char_start == l.char_end
                    && !l.runs.is_empty()
                    && l.runs.iter().all(|r| r.reveal_tag)
            })
        };
        let phantom_width = (60..=400).step_by(2).find(|&w| {
            let mut d = Renderer::new(0, 0, w, 300);
            d.editor_mut().set_document(doc.clone());
            d.set_reveal_codes(true);
            let mut ctx = TestRenderContext::new_with_focus();
            d.layout(&mut ctx);
            is_phantom(&d)
        });
        let w = phantom_width.expect("no width wrapped a reveal tag onto its own line");

        let mut display = Renderer::new(0, 0, w, 300);
        display.editor_mut().set_document(doc.clone());
        display.set_reveal_codes(true);
        display.editor_mut().set_cursor(DocumentPosition::new(0, 0));
        let mut ctx = TestRenderContext::new_with_focus();

        // Press Down repeatedly: the caret must reach the trailing "AFTER"
        // paragraph (the last leaf), not freeze on the bold list item.
        let last_path = TreePath::root(1);
        let mut prev = display.editor().cursor();
        let mut reached = false;
        for _ in 0..50 {
            display.move_cursor_visual_down(false, &mut ctx);
            let cur = display.editor().cursor();
            if cur.path == last_path {
                reached = true;
                break;
            }
            if cur == prev {
                break; // stuck before the end
            }
            prev = cur;
        }
        assert!(
            reached,
            "Down stalled before the trailing paragraph at {:?} (phantom width {w})",
            display.editor().cursor(),
        );
    }

    #[test]
    fn test_display_creation() {
        let display = Renderer::new(0, 0, 800, 600);
        assert_eq!(display.w(), 800);
        assert_eq!(display.h(), 600);
    }

    #[test]
    fn cursor_in_empty_blockquote_respects_indent() {
        let block = Block::new(BlockType::BlockQuote);
        let mut display = make_display_with_block(block);
        let mut ctx = TestRenderContext::new_with_focus();

        display.layout(&mut ctx);
        let (x, _, _) = display.get_cursor_visual_position(&mut ctx).unwrap();
        assert_eq!(x, display.theme.padding_horizontal + 20);
    }

    #[test]
    fn cursor_in_empty_unordered_list_respects_content_indent() {
        let block = Block::new(BlockType::ListItem {
            ordered: false,
            number: None,
            checkbox: None,
            depth: 0,
        });
        let mut display = make_display_with_block(block);
        let mut ctx = TestRenderContext::new_with_focus();

        let bullet_width = ctx.text_width(
            "• ",
            display.theme.plain_text.font_type,
            display.theme.plain_text.font_style,
            display.theme.plain_text.font_size,
        ) as i32;
        let expected_x = display.theme.padding_horizontal
            + display.theme.plain_text.font_size as i32
            + bullet_width;

        display.layout(&mut ctx);
        let (x, _, _) = display.get_cursor_visual_position(&mut ctx).unwrap();
        assert_eq!(x, expected_x);
    }

    #[test]
    fn cursor_in_empty_ordered_list_respects_content_indent() {
        // A single ordered item renders as "1." (ordinals are derived from tree position).
        let block = Block::new(BlockType::ListItem {
            ordered: true,
            number: Some(1),
            checkbox: None,
            depth: 0,
        });
        let mut display = make_display_with_block(block);
        let mut ctx = TestRenderContext::new_with_focus();

        let label_width = ctx.text_width(
            "1. ",
            display.theme.plain_text.font_type,
            display.theme.plain_text.font_style,
            display.theme.plain_text.font_size,
        ) as i32;
        let expected_x = display.theme.padding_horizontal
            + display.theme.plain_text.font_size as i32
            + label_width;

        display.layout(&mut ctx);
        let (x, _, _) = display.get_cursor_visual_position(&mut ctx).unwrap();
        assert_eq!(x, expected_x);
    }

    #[test]
    fn cursor_in_empty_checklist_respects_content_indent() {
        let block = Block::new(BlockType::ListItem {
            ordered: false,
            number: None,
            checkbox: Some(false),
            depth: 0,
        });
        let mut display = make_display_with_block(block);
        let mut ctx = TestRenderContext::new_with_focus();

        let mut checkbox_size = (display.theme.plain_text.font_size as i32).saturating_sub(4);
        if checkbox_size < 8 {
            checkbox_size = 8;
        }
        if checkbox_size > display.theme.line_height {
            checkbox_size = display.theme.line_height;
        }

        let mut space_width = ctx.text_width(
            " ",
            display.theme.plain_text.font_type,
            display.theme.plain_text.font_style,
            display.theme.plain_text.font_size,
        ) as i32;
        if space_width < 4 {
            space_width = 4;
        }

        let expected_x = display.theme.padding_horizontal
            + display.theme.plain_text.font_size as i32
            + checkbox_size
            + space_width;

        display.layout(&mut ctx);
        let (x, _, _) = display.get_cursor_visual_position(&mut ctx).unwrap();
        assert_eq!(x, expected_x);
    }

    #[test]
    fn cursor_in_empty_code_block_respects_indent() {
        let block = Block::new(BlockType::CodeBlock { language: None });
        let mut display = make_display_with_block(block);
        let mut ctx = TestRenderContext::new_with_focus();

        display.layout(&mut ctx);
        let (x, _, _) = display.get_cursor_visual_position(&mut ctx).unwrap();
        assert_eq!(x, display.theme.padding_horizontal + 10);
    }

    #[test]
    fn trailing_hard_break_creates_empty_visual_line() {
        let mut block = Block::paragraph();
        block
            .content
            .push(InlineContent::Text(TextRun::plain("Hello")));
        block.content.push(InlineContent::HardBreak);

        let mut display = make_display_with_block(block);

        let mut layout_ctx = TestRenderContext::new_with_focus();
        display.layout(&mut layout_ctx);

        assert_eq!(
            display.layout_lines.len(),
            2,
            "Trailing hard break should produce two visual lines"
        );

        let end_offset = leaf0_len(&display);
        {
            let editor = display.editor_mut();
            editor.set_cursor(DocumentPosition::new(0, end_offset));
        }

        let trailing_line = display
            .layout_lines
            .last()
            .expect("Expected trailing layout line for hard break");
        assert_eq!(trailing_line.char_start, end_offset);
        assert_eq!(trailing_line.char_end, end_offset);

        let mut ctx = TestRenderContext::new_with_focus();
        let (cursor_x, cursor_y, _) = display
            .get_cursor_visual_position(&mut ctx)
            .expect("Cursor position should resolve with trailing hard break");
        assert_eq!(cursor_x, trailing_line.base_x);
        assert_eq!(cursor_y, trailing_line.y);
    }

    #[test]
    fn cursor_home_end_respect_visual_lines() {
        let block = Block::paragraph().with_plain_text(
            "This visual line wrapping test ensures Home and End stay within wraps.",
        );
        let mut display = make_display_with_block(block);
        display.resize(0, 0, 160, 200);

        let mut layout_ctx = TestRenderContext::new_with_focus();
        display.layout(&mut layout_ctx);

        assert!(
            display.layout_lines.len() >= 2,
            "Expected wrapped layout to produce multiple visual lines"
        );

        let first_line_start = display.layout_lines[0].char_start;
        let first_line_end = display.layout_lines[0].char_end;
        let second_line_start = display.layout_lines[1].char_start;
        let first_line_base_x = display.layout_lines[0].base_x;
        let first_line_y = display.layout_lines[0].y;
        let second_line_y = display.layout_lines[1].y;
        let second_line_base_x = display.layout_lines[1].base_x;

        {
            let editor = display.editor_mut();
            editor.set_cursor(DocumentPosition::new(0, first_line_start));
        }

        let mut ctx = TestRenderContext::new_with_focus();
        let mut expected_end_offset = first_line_end;
        while expected_end_offset > first_line_start
            && !display.offset_belongs_to_line(0, expected_end_offset)
        {
            expected_end_offset -= 1;
        }

        display.move_cursor_visual_line_end_precise(false, &mut ctx);
        assert_eq!(display.editor().cursor().offset, expected_end_offset);
        let (end_x, end_y, _) = display.get_cursor_visual_position(&mut ctx).unwrap();
        assert_eq!(end_y, first_line_y);
        assert!(end_x > first_line_base_x);

        {
            let editor = display.editor_mut();
            editor.move_cursor_right();
        }
        let (wrapped_x, wrapped_y, _) = display.get_cursor_visual_position(&mut ctx).unwrap();
        assert_eq!(display.editor().cursor().offset, second_line_start);
        assert_eq!(wrapped_y, second_line_y);
        assert_eq!(wrapped_x, second_line_base_x);

        let second_line_end = display.layout_lines[1].char_end;
        let sample_offset = (second_line_start + 3).min(second_line_end);
        {
            let editor = display.editor_mut();
            editor.set_cursor(DocumentPosition::new(0, sample_offset));
        }

        display.move_cursor_visual_line_start(false, &mut ctx);
        assert_eq!(display.editor().cursor().offset, second_line_start);
        let (start_x, start_y, _) = display.get_cursor_visual_position(&mut ctx).unwrap();
        assert_eq!(start_y, second_line_y);
        assert_eq!(start_x, second_line_base_x);
    }

    #[test]
    fn hard_break_blank_line_has_nonzero_offset() {
        let mut block = Block::paragraph();
        block
            .content
            .push(InlineContent::Text(TextRun::plain("Hello")));
        block.content.push(InlineContent::HardBreak);
        block.content.push(InlineContent::HardBreak);
        block
            .content
            .push(InlineContent::Text(TextRun::plain("World")));
        let mut display = make_display_with_block(block);

        let mut ctx = TestRenderContext::new_with_focus();
        display.layout_valid = false;
        display.layout(&mut ctx);

        assert!(
            display.layout_lines.len() >= 3,
            "expected at least three visual lines, got {}",
            display.layout_lines.len()
        );

        let zero_len_offsets: Vec<usize> = display
            .layout_lines
            .iter()
            .filter(|line| line.char_start == line.char_end)
            .map(|line| line.char_start)
            .collect();

        assert!(
            zero_len_offsets.iter().any(|&offset| offset > 0),
            "expected a zero-length line with non-zero offset, got offsets {:?}",
            zero_len_offsets
        );
    }
}
