Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,13 +331,15 @@ impl App {

pub(crate) fn active_toc_index(&self) -> Option<usize> {
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));
Expand All @@ -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)
Expand Down
78 changes: 2 additions & 76 deletions src/tests/markdown_blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Expand Down
151 changes: 151 additions & 0 deletions src/tests/toc.rs
Original file line number Diff line number Diff line change
@@ -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<TocEntry> {
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<TocEntry>) -> App {
let (ss, theme) = test_assets();
let md = (0..total_lines)
.map(|_| "line")
.collect::<Vec<_>>()
.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);
}
Loading