edamame 0.1.5

A fast TUI Markdown editor and viewer
Documentation
//! "Go to section" support: build the heading list at modal-open time, debounce live-preview
//! scroll while the modal is open, and apply the confirm / cancel transitions when it closes.
//!
//! The debounce mirrors the autosave shape — an `Option<Instant>` on `App`, contributed to
//! [`App::next_deadline`] via [`App::section_jump_deadline`] and drained in
//! [`App::tick_section_jump`].

use std::time::{Duration, Instant};

use crate::editor::Mode;
use crate::markdown::{ast::heading_plain_text, Block};
use crate::ui::HeadingEntry;

use super::App;

/// Debounce window for live-preview scrolls: long enough to absorb a held arrow key's autorepeat
/// (~50 ms), short enough that a tap feels immediate.
pub(super) const SECTION_JUMP_DELAY: Duration = Duration::from_millis(150);

impl App {
    /// Open the "Go to section" modal, preselecting the nearest heading at or before the cursor.
    ///
    /// `doc_width` must be the live document area width, so the modal's previewed `target_scroll`
    /// lands at the same row the editor will paint.
    pub fn open_section_picker(&mut self, doc_width: usize) {
        let entries = collect_heading_entries(&self.editor, doc_width);
        let cursor_line = self.editor.buffer.char_to_line(self.editor.cursor.offset);
        let focused = preselected_index(&entries, cursor_line);
        let original_scroll = self.editor.scroll;
        self.modal_stack
            .push(Box::new(super::modal::SectionPickerModal::new(
                entries,
                focused,
                original_scroll,
            )));
        self.needs_draw = true;
    }

    /// Arm (or extend) the debounce window; the run loop applies the most-recent `target_scroll`
    /// once [`SECTION_JUMP_DELAY`] elapses.
    pub(crate) fn arm_section_jump(&mut self, target_scroll: usize) {
        self.section_jump_pending_since = Some(Instant::now());
        self.section_jump_target_scroll = Some(target_scroll);
    }

    /// Esc path: restore the viewport snapshot, clearing any pending preview so a mid-flight
    /// debounce can't fire after the revert.
    pub(crate) fn cancel_section_jump(&mut self, original_scroll: usize) {
        self.section_jump_pending_since = None;
        self.section_jump_target_scroll = None;
        if self.editor.scroll != original_scroll {
            self.editor.scroll = original_scroll;
            self.mark_scrolling();
        }
        self.needs_draw = true;
    }

    /// Enter path: apply the target scroll immediately and move the cursor to the end of
    /// `buffer_line`.  The cursor move is invisible in Preview mode but still recorded, so a later
    /// mode switch lands in the right place.
    pub(crate) fn commit_section_jump(&mut self, buffer_line: usize, target_scroll: usize) {
        self.section_jump_pending_since = None;
        self.section_jump_target_scroll = None;
        let scroll_changed = self.editor.scroll != target_scroll;
        self.editor.scroll = target_scroll;
        let end_offset = end_of_line_offset(&self.editor, buffer_line);
        self.editor.cursor.offset = end_offset;
        self.editor.cursor.preferred_col = self.editor.cursor.cell_col(&self.editor.buffer);
        self.editor.update_cursor_block();
        if scroll_changed {
            self.mark_scrolling();
        }
        self.needs_draw = true;
    }

    /// Per-iteration debounce step: apply the stashed target scroll once the window elapses.
    pub(super) fn tick_section_jump(&mut self) {
        let Some(since) = self.section_jump_pending_since else {
            return;
        };
        if since.elapsed() < SECTION_JUMP_DELAY {
            return;
        }
        if let Some(target) = self.section_jump_target_scroll.take() {
            if self.editor.scroll != target {
                self.editor.scroll = target;
                self.mark_scrolling();
                self.needs_draw = true;
            }
        }
        self.section_jump_pending_since = None;
    }

    /// Earliest instant the run loop must wake to apply a pending section-jump scroll.
    pub(super) fn section_jump_deadline(&self) -> Option<Instant> {
        self.section_jump_pending_since
            .map(|t| t + SECTION_JUMP_DELAY)
    }
}

/// Build the heading-entry list, computing `target_scroll` for the editor's current mode.
fn collect_heading_entries(
    state: &crate::editor::EditorState,
    doc_width: usize,
) -> Vec<HeadingEntry> {
    let mut entries: Vec<HeadingEntry> = Vec::new();
    let width = doc_width.max(1);
    for (block_idx, block) in state.parsed.blocks.iter().enumerate() {
        let Block::Heading { level, inlines } = block else {
            continue;
        };
        let text = heading_plain_text(inlines);
        let Some(range) = state.parsed.real_ranges.get(block_idx) else {
            continue;
        };
        let byte_start = range.start;
        let buffer_line = state.buffer.byte_to_line(byte_start);
        let target_scroll = match state.mode {
            Mode::Raw => state.visual_rows_before_raw_line(buffer_line, width),
            _ => {
                // `block_idx` indexes real blocks only; the source map's index space also has
                // blank-line virtual blocks, so `rendered_lines_for_block(block_idx)` would land
                // on the wrong block.  Route through the byte lookup instead.
                let rendered = state.parsed.source_map.rendered_lines_for_byte(byte_start);
                state.parsed.visual_rows_before(rendered.start, width)
            }
        };
        entries.push(HeadingEntry {
            level: *level,
            text,
            buffer_line,
            target_scroll,
        });
    }
    entries
}

/// Index of the heading with the largest `buffer_line <= cursor_line`; `0` when there is none.
/// The `take_while` is sound only because `entries` is in document order.
fn preselected_index(entries: &[HeadingEntry], cursor_line: usize) -> usize {
    entries
        .iter()
        .take_while(|e| e.buffer_line <= cursor_line)
        .count()
        .saturating_sub(1)
}

/// Char offset of the end of `line_idx`, before any trailing `\n`.
fn end_of_line_offset(state: &crate::editor::EditorState, line_idx: usize) -> usize {
    let line_count = state.buffer.line_count();
    if line_count == 0 {
        return 0;
    }
    let idx = line_idx.min(line_count - 1);
    let start = state.buffer.line_to_char(idx);
    let line_slice = state.buffer.rope().line(idx);
    let len = line_slice.len_chars();
    // Trim the `\n` so the cursor lands before it, not at column 0 of the next line.  The buffer
    // is `\n`-normalized on load, so there is no `\r` to account for.
    let trim = usize::from(len > 0 && line_slice.char(len - 1) == '\n');
    start + len.saturating_sub(trim)
}

#[cfg(test)]
mod tests {
    use pulldown_cmark::HeadingLevel;

    use super::*;
    use crate::app::test_utils::make_app;
    use crate::editor::Mode;

    fn load(app: &mut App, src: &str) {
        app.editor.buffer = crate::document::Buffer::from_str(src);
        app.editor.refresh_parsed();
    }

    #[test]
    fn collect_entries_target_scroll_is_correct_with_blank_separator_blocks() {
        // Regression: indexing `rendered_lines_for_block` with the `parsed.blocks` index skewed
        // every heading after a blank line.
        let mut app = make_app();
        load(&mut app, "# First\n\n## Second\n\nbody\n");
        app.editor.mode = Mode::Rendered;
        let entries = collect_heading_entries(&app.editor, 80);
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].target_scroll, 0);
        assert!(
            entries[1].target_scroll >= 2,
            "target_scroll for second heading was {}; expected at least 2 \
             rendered rows (heading + blank) before it",
            entries[1].target_scroll
        );
    }

    #[test]
    fn collect_entries_includes_every_heading() {
        let mut app = make_app();
        load(&mut app, "# A\n\nsome text\n\n## B\n\n### C\n\nmore text\n");
        let entries = collect_heading_entries(&app.editor, 80);
        let texts: Vec<&str> = entries.iter().map(|e| e.text.as_str()).collect();
        assert_eq!(texts, vec!["A", "B", "C"]);
    }

    #[test]
    fn preselected_picks_largest_heading_at_or_before_cursor() {
        let entries = vec![
            HeadingEntry {
                level: HeadingLevel::H1,
                text: "Top".into(),
                buffer_line: 0,
                target_scroll: 0,
            },
            HeadingEntry {
                level: HeadingLevel::H2,
                text: "Middle".into(),
                buffer_line: 5,
                target_scroll: 6,
            },
            HeadingEntry {
                level: HeadingLevel::H2,
                text: "Bottom".into(),
                buffer_line: 20,
                target_scroll: 30,
            },
        ];
        assert_eq!(preselected_index(&entries, 0), 0);
        assert_eq!(preselected_index(&entries, 3), 0);
        assert_eq!(preselected_index(&entries, 5), 1);
        assert_eq!(preselected_index(&entries, 19), 1);
        assert_eq!(preselected_index(&entries, 100), 2);
    }

    #[test]
    fn preselected_returns_zero_for_empty_or_cursor_above_first() {
        assert_eq!(preselected_index(&[], 0), 0);
        let entries = vec![HeadingEntry {
            level: HeadingLevel::H1,
            text: "Only".into(),
            buffer_line: 4,
            target_scroll: 0,
        }];
        assert_eq!(preselected_index(&entries, 0), 0);
    }

    #[test]
    fn end_of_line_offset_lands_before_trailing_newline() {
        let mut app = make_app();
        load(&mut app, "abc\ndef\n");
        assert_eq!(end_of_line_offset(&app.editor, 0), 3);
        assert_eq!(end_of_line_offset(&app.editor, 1), 7);
    }

    #[test]
    fn end_of_line_offset_handles_crlf_line_endings() {
        let mut app = make_app();
        // CRLF is normalized to `\n` on load, so offsets match the LF file above.
        load(&mut app, "abc\r\ndef\r\n");
        assert_eq!(end_of_line_offset(&app.editor, 0), 3);
        assert_eq!(end_of_line_offset(&app.editor, 1), 7);
    }

    #[test]
    fn commit_section_jump_moves_cursor_to_end_of_heading_line() {
        let mut app = make_app();
        load(&mut app, "# Heading one\n\nbody\n");
        app.editor.mode = Mode::Rendered;
        app.commit_section_jump(0, 0);
        assert_eq!(app.editor.cursor.offset, 13);
    }

    #[test]
    fn cancel_section_jump_restores_original_scroll() {
        let mut app = make_app();
        load(&mut app, "# Hi\n\nbody\n");
        app.editor.scroll = 7;
        app.cancel_section_jump(2);
        assert_eq!(app.editor.scroll, 2);
        assert!(app.section_jump_pending_since.is_none());
        assert!(app.section_jump_target_scroll.is_none());
    }

    #[test]
    fn arm_then_cancel_clears_pending_preview() {
        let mut app = make_app();
        app.arm_section_jump(10);
        assert!(app.section_jump_pending_since.is_some());
        app.cancel_section_jump(0);
        assert!(app.section_jump_pending_since.is_none());
        assert!(app.section_jump_target_scroll.is_none());
    }

    #[test]
    fn tick_applies_target_after_window_elapses() {
        let mut app = make_app();
        app.arm_section_jump(5);
        // Force the timestamp into the past so the tick fires.
        app.section_jump_pending_since =
            Some(Instant::now() - SECTION_JUMP_DELAY - Duration::from_millis(5));
        app.tick_section_jump();
        assert_eq!(app.editor.scroll, 5);
        assert!(app.section_jump_pending_since.is_none());
        assert!(app.section_jump_target_scroll.is_none());
    }

    #[test]
    fn tick_no_op_before_window_elapses() {
        let mut app = make_app();
        app.arm_section_jump(5);
        app.tick_section_jump();
        assert_eq!(app.editor.scroll, 0);
        assert!(app.section_jump_pending_since.is_some());
    }
}