diff --git a/src/app/mod.rs b/src/app/mod.rs index 1130d10..4fb0961 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -331,13 +331,15 @@ impl App { pub(crate) fn active_toc_index(&self) -> Option { let hide_single_h1 = should_hide_single_h1(&self.toc); + let is_visible = |entry: &&TocEntry| !(hide_single_h1 && entry.level == 1); + let mut first_visible = None; let mut active = None; for (idx, entry) in self .toc .iter() .enumerate() - .filter(|(_, entry)| !(hide_single_h1 && entry.level == 1)) + .filter(|(_, entry)| is_visible(entry)) { if first_visible.is_none() { first_visible = Some((idx, entry.line)); @@ -348,6 +350,17 @@ impl App { active = Some(idx); } + let max = self.max_scroll(); + if max > 0 && self.scroll >= max { + let last_visible = self + .toc + .iter() + .enumerate() + .rfind(|(_, entry)| is_visible(entry)) + .map(|(idx, _)| idx); + active = last_visible; + } + let (first_idx, first_line) = first_visible?; if self.scroll < first_line { Some(first_idx) diff --git a/src/tests/markdown_blocks.rs b/src/tests/markdown_blocks.rs index 1352c96..d7e9070 100644 --- a/src/tests/markdown_blocks.rs +++ b/src/tests/markdown_blocks.rs @@ -54,33 +54,14 @@ fn long_blockquotes_wrap_into_multiple_prefixed_lines() { } #[test] -fn toc_only_includes_first_two_heading_levels() { - let (ss, theme) = test_assets(); - let (_, toc, _) = parse_markdown( - "# One\n## Two\n### Three\n#### Four\n", - &ss, - &theme, - &test_md_theme(), - false, - ); - - assert_eq!(toc.len(), 3); - assert_eq!(toc[0].level, 1); - assert_eq!(toc[1].level, 2); - assert_eq!(toc[2].level, 3); -} - -#[test] -fn frontmatter_is_ignored_in_preview_and_toc() { +fn frontmatter_is_ignored_in_preview() { let (ss, theme) = test_assets(); let src = "---\ntitle: Demo\nowner: me\n---\n# Visible\nBody\n"; - let (lines, toc, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false); + let (lines, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false); let rendered = rendered_non_empty_lines(&lines); assert!(!rendered.iter().any(|line| line.contains("title: Demo"))); assert!(rendered.iter().any(|line| line.contains("Visible"))); - assert_eq!(toc.len(), 1); - assert_eq!(toc[0].title, "Visible"); } #[test] @@ -121,58 +102,3 @@ fn rules_use_render_width_without_extra_blank_after() { let rule_idx = rendered.iter().position(|line| line == rule).unwrap(); assert_eq!(rendered[rule_idx + 1], "Beta"); } - -#[test] -fn toc_hides_single_h1_when_h2_entries_exist() { - let toc = vec![ - TocEntry { - level: 1, - title: "Doc Title".to_string(), - line: 0, - }, - TocEntry { - level: 2, - title: "Install".to_string(), - line: 10, - }, - ]; - - assert!(should_hide_single_h1(&toc)); - assert_eq!(toc_display_level(2, true, false), 1); - assert_eq!(toc_display_level(3, true, false), 2); -} - -#[test] -fn toc_keeps_single_h1_when_no_h2_entries_exist() { - let toc = vec![TocEntry { - level: 1, - title: "Doc Title".to_string(), - line: 0, - }]; - - assert!(!should_hide_single_h1(&toc)); -} - -#[test] -fn toc_promotes_h2_when_document_has_no_h1() { - let toc = vec![ - TocEntry { - level: 2, - title: "Build & install".to_string(), - line: 0, - }, - TocEntry { - level: 3, - title: "Android".to_string(), - line: 4, - }, - ]; - - assert!(should_promote_h2_when_no_h1(&toc)); - assert_eq!(toc_display_level(2, false, true), 1); - assert_eq!(toc_display_level(3, false, true), 2); - let normalized = normalize_toc(toc); - assert_eq!(normalized.len(), 2); - assert_eq!(normalized[0].level, 2); - assert_eq!(normalized[1].level, 3); -} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 75df510..77a86c1 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -24,6 +24,7 @@ mod markdown_lists; mod markdown_tables; mod render; mod theme; +mod toc; mod update; pub(super) static THEME_TEST_MUTEX: Mutex<()> = Mutex::new(()); diff --git a/src/tests/toc.rs b/src/tests/toc.rs new file mode 100644 index 0000000..66d630d --- /dev/null +++ b/src/tests/toc.rs @@ -0,0 +1,151 @@ +use super::{test_assets, test_md_theme}; +use crate::app::App; +use crate::markdown::parse_markdown; +use crate::*; +use ratatui::layout::Rect; + +fn toc(entries: &[(u8, usize)]) -> Vec { + entries + .iter() + .enumerate() + .map(|(i, (level, line))| TocEntry { + level: *level, + title: format!("Section {}", i + 1), + line: *line, + }) + .collect() +} + +fn make_app_with_toc(total_lines: usize, viewport_height: u16, toc: Vec) -> App { + let (ss, theme) = test_assets(); + let md = (0..total_lines) + .map(|_| "line") + .collect::>() + .join("\n"); + let (lines, _, _) = parse_markdown(&md, &ss, &theme, &test_md_theme(), false); + let mut app = App::new(lines, toc, "test".to_string(), false, false, None, None); + app.content_area = Rect::new(0, 0, 80, viewport_height); + app +} + +#[test] +fn active_toc_highlights_last_header_when_short_section_at_bottom() { + let mut app = make_app_with_toc(100, 15, toc(&[(2, 0), (2, 30), (2, 70), (2, 95)])); + app.scroll_bottom(); + assert_eq!(app.active_toc_index(), Some(3)); +} + +#[test] +fn active_toc_unchanged_when_document_fits_in_viewport() { + let mut app = make_app_with_toc(10, 20, toc(&[(2, 0), (2, 5)])); + app.scroll_bottom(); + assert_eq!(app.active_toc_index(), Some(0)); +} + +#[test] +fn active_toc_last_header_with_long_section_uses_existing_logic() { + let mut app = make_app_with_toc(100, 15, toc(&[(2, 0), (2, 30), (2, 50)])); + app.scroll_bottom(); + assert_eq!(app.active_toc_index(), Some(2)); +} + +#[test] +fn active_toc_intermediate_header() { + let mut app = make_app_with_toc(100, 15, toc(&[(2, 0), (2, 30), (2, 70)])); + app.scroll = 40; + assert_eq!(app.active_toc_index(), Some(1)); +} + +#[test] +fn active_toc_empty_toc_returns_none() { + let app = make_app_with_toc(50, 15, vec![]); + assert_eq!(app.active_toc_index(), None); +} + +#[test] +fn active_toc_single_header() { + let app = make_app_with_toc(50, 15, toc(&[(2, 0)])); + assert_eq!(app.active_toc_index(), Some(0)); +} + +#[test] +fn toc_only_includes_first_two_heading_levels() { + let (ss, theme) = test_assets(); + let (_, toc, _) = parse_markdown( + "# One\n## Two\n### Three\n#### Four\n", + &ss, + &theme, + &test_md_theme(), + false, + ); + + assert_eq!(toc.len(), 3); + assert_eq!(toc[0].level, 1); + assert_eq!(toc[1].level, 2); + assert_eq!(toc[2].level, 3); +} + +#[test] +fn frontmatter_is_ignored_in_toc() { + let (ss, theme) = test_assets(); + let src = "---\ntitle: Demo\nowner: me\n---\n# Visible\nBody\n"; + let (_, toc, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false); + + assert_eq!(toc.len(), 1); + assert_eq!(toc[0].title, "Visible"); +} + +#[test] +fn toc_hides_single_h1_when_h2_entries_exist() { + let toc = vec![ + TocEntry { + level: 1, + title: "Doc Title".to_string(), + line: 0, + }, + TocEntry { + level: 2, + title: "Install".to_string(), + line: 10, + }, + ]; + + assert!(should_hide_single_h1(&toc)); + assert_eq!(toc_display_level(2, true, false), 1); + assert_eq!(toc_display_level(3, true, false), 2); +} + +#[test] +fn toc_keeps_single_h1_when_no_h2_entries_exist() { + let toc = vec![TocEntry { + level: 1, + title: "Doc Title".to_string(), + line: 0, + }]; + + assert!(!should_hide_single_h1(&toc)); +} + +#[test] +fn toc_promotes_h2_when_document_has_no_h1() { + let toc = vec![ + TocEntry { + level: 2, + title: "Build & install".to_string(), + line: 0, + }, + TocEntry { + level: 3, + title: "Android".to_string(), + line: 4, + }, + ]; + + assert!(should_promote_h2_when_no_h1(&toc)); + assert_eq!(toc_display_level(2, false, true), 1); + assert_eq!(toc_display_level(3, false, true), 2); + let normalized = normalize_toc(toc); + assert_eq!(normalized.len(), 2); + assert_eq!(normalized[0].level, 2); + assert_eq!(normalized[1].level, 3); +}