use std::time::{Duration, Instant};
use crate::editor::Mode;
use crate::markdown::{ast::heading_plain_text, Block};
use crate::ui::HeadingEntry;
use super::App;
pub(super) const SECTION_JUMP_DELAY: Duration = Duration::from_millis(150);
impl App {
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;
}
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);
}
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;
}
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;
}
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;
}
pub(super) fn section_jump_deadline(&self) -> Option<Instant> {
self.section_jump_pending_since
.map(|t| t + SECTION_JUMP_DELAY)
}
}
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),
_ => {
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
}
fn preselected_index(entries: &[HeadingEntry], cursor_line: usize) -> usize {
entries
.iter()
.take_while(|e| e.buffer_line <= cursor_line)
.count()
.saturating_sub(1)
}
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();
let trim = if len > 0 && line_slice.char(len - 1) == '\n' {
if len >= 2 && line_slice.char(len - 2) == '\r' {
2
} else {
1
}
} else {
0
};
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() {
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();
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), 8);
}
#[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);
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());
}
}