From 800a800b5abe800b78efe670c8833a402d060f86 Mon Sep 17 00:00:00 2001 From: RivoLink Date: Sat, 4 Jul 2026 09:40:12 +0300 Subject: [PATCH] feat: toc scroll auto-follow --- src/app/content.rs | 2 + src/app/goto_line.rs | 1 + src/app/mod.rs | 29 +++- src/app/navigation.rs | 181 +++++++++++++++++++- src/app/search.rs | 3 + src/render/popup.rs | 6 +- src/render/status.rs | 10 ++ src/render/toc.rs | 7 + src/runtime/keyboard.rs | 8 + src/runtime/mouse.rs | 27 ++- src/tests/toc.rs | 361 +++++++++++++++++++++++++++++++++++++++- 11 files changed, 624 insertions(+), 11 deletions(-) diff --git a/src/app/content.rs b/src/app/content.rs index ba6d944..42f61e3 100644 --- a/src/app/content.rs +++ b/src/app/content.rs @@ -54,6 +54,7 @@ impl App { pub(crate) fn reload(&mut self, ss: &SyntaxSet, themes: &ThemeSet) -> bool { self.reset_numkey_state(); + self.reset_toc_scroll_mode(); let path = match &self.filepath { Some(p) => p, None => return false, @@ -129,6 +130,7 @@ impl App { self.invalidate_theme_preview_cache(); self.store_current_theme_preview_from(&parsed.lines, &parsed.toc); self.replace_content(parsed); + self.reset_toc_scroll_mode(); true } diff --git a/src/app/goto_line.rs b/src/app/goto_line.rs index 70442c8..6b21423 100644 --- a/src/app/goto_line.rs +++ b/src/app/goto_line.rs @@ -71,6 +71,7 @@ impl App { if let Some(render_index) = self.find_render_index_for_logical(logical) { self.goto_line.target = Some(render_index); self.goto_line.error = false; + self.reset_toc_scroll_mode(); let scroll_pos = render_index.saturating_sub(GOTO_LINE_CONTEXT_OFFSET); self.scroll = scroll_pos.min(self.max_scroll()); } else { diff --git a/src/app/mod.rs b/src/app/mod.rs index f12443c..d786fa6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -48,6 +48,12 @@ mod io_picker; mod theme_picker; pub(crate) use theme_picker::ThemePickerState; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TocScrollMode { + Auto, + Manual(usize), +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct StatusCacheKey { pct: u16, @@ -74,6 +80,7 @@ pub(crate) struct StatusCacheKey { path_flash_active: bool, code_block_flash_active: bool, mouse_capture: bool, + toc_scroll_hint_visible: bool, } pub(crate) struct AppConfig { @@ -113,7 +120,11 @@ pub(crate) struct App { toc_display_entries: Vec, toc_header_line: Line<'static>, pub(crate) toc_active_idx: Option, + pub(crate) toc_active_display_idx: Option, pub(crate) hovered_toc_idx: Option, + toc_scroll_mode: TocScrollMode, + toc_scroll_hint_dismissed: bool, + toc_active_pin: Option, status_line: Line<'static>, status_cache_key: Option, pub(super) help_open: bool, @@ -246,7 +257,11 @@ impl App { toc_display_entries: Vec::new(), toc_header_line: toc_header_line(), toc_active_idx: None, + toc_active_display_idx: None, hovered_toc_idx: None, + toc_scroll_mode: TocScrollMode::Auto, + toc_scroll_hint_dismissed: false, + toc_active_pin: None, status_line: Line::default(), status_cache_key: None, help_open: false, @@ -459,6 +474,9 @@ impl App { } pub(crate) fn active_toc_index(&self) -> Option { + if let Some(pin) = self.toc_active_pin { + return Some(pin); + } let mut first_visible = None; let mut active = None; let mut last_visible = None; @@ -514,22 +532,28 @@ impl App { let mut top_level_index = 0usize; let mut lines: Vec> = Vec::new(); let mut entries: Vec = Vec::new(); + let mut active_display_idx = None; for (idx, entry, display_level) in self.visible_toc_entries() { + let is_active = active_idx == Some(idx); let line = build_toc_line_with_index( entry, display_level, (display_level == 1).then_some(top_level_index), - active_idx == Some(idx), + is_active, ); if display_level == 1 { top_level_index += 1; } + if is_active { + active_display_idx = Some(entries.len()); + } lines.push(line); entries.push(idx); } debug_assert_eq!(lines.len(), entries.len()); self.toc_display_lines = lines; self.toc_display_entries = entries; + self.toc_active_display_idx = active_display_idx; } pub(crate) fn refresh_status_cache(&mut self, pct: u16) { @@ -585,6 +609,7 @@ impl App { .map(|(_, t)| t.elapsed() < Duration::from_millis(FLASH_DURATION_MS)) .unwrap_or(false), mouse_capture: self.mouse_capture, + toc_scroll_hint_visible: self.is_toc_scroll_hint_visible(), }; if self.status_cache_key.as_ref() == Some(&cache_key) { @@ -597,6 +622,8 @@ impl App { pub(crate) fn refresh_static_caches(&mut self) { self.toc_active_idx = None; + self.toc_active_display_idx = None; + self.toc_active_pin = None; self.toc_display_lines.clear(); self.toc_display_entries.clear(); self.refresh_toc_cache(); diff --git a/src/app/navigation.rs b/src/app/navigation.rs index 99782a1..bcfe44a 100644 --- a/src/app/navigation.rs +++ b/src/app/navigation.rs @@ -1,4 +1,4 @@ -use super::App; +use super::{App, TocScrollMode}; pub(super) enum CycleDirection { Forward, @@ -39,26 +39,31 @@ impl App { pub(crate) fn scroll_down(&mut self, n: usize) { self.reset_numkey_state(); + self.reset_toc_scroll_mode(); self.scroll = (self.scroll + n).min(self.max_scroll()); } pub(crate) fn scroll_up(&mut self, n: usize) { self.reset_numkey_state(); + self.reset_toc_scroll_mode(); self.scroll = self.scroll.saturating_sub(n); } pub(crate) fn scroll_top(&mut self) { self.reset_numkey_state(); + self.reset_toc_scroll_mode(); self.scroll = 0; } pub(crate) fn scroll_bottom(&mut self) { self.reset_numkey_state(); + self.reset_toc_scroll_mode(); self.scroll = self.max_scroll(); } pub(crate) fn scroll_to(&mut self, position: usize) { self.reset_numkey_state(); + self.reset_toc_scroll_mode(); self.scroll = position.min(self.max_scroll()); } @@ -67,6 +72,7 @@ impl App { if !self.toc_visible { self.hovered_toc_idx = None; } + self.reset_toc_scroll_mode(); } pub(crate) fn toggle_line_numbers(&mut self) { @@ -96,11 +102,17 @@ impl App { } pub(crate) fn scroll_to_toc_display_line(&mut self, display_idx: usize) { - if let Some(&entry_idx) = self.toc_display_entries.get(display_idx) { - if let Some(entry) = self.toc.get(entry_idx) { - self.scroll_to(entry.line); - } - } + let Some(&entry_idx) = self.toc_display_entries.get(display_idx) else { + return; + }; + let Some(entry_line) = self.toc.get(entry_idx).map(|e| e.line) else { + return; + }; + let preserved_offset = self.current_toc_offset(); + self.reset_numkey_state(); + self.scroll = entry_line.min(self.max_scroll()); + self.toc_scroll_mode = TocScrollMode::Manual(preserved_offset); + self.toc_active_pin = Some(entry_idx); } pub(crate) fn cycle_numkey(&mut self, key: u8) { @@ -109,6 +121,8 @@ impl App { return; } + self.reset_toc_scroll_mode(); + let direction = if self.reverse_mode { CycleDirection::Backward } else { @@ -129,4 +143,159 @@ impl App { self.numkey_cycle = Some(NumkeyCycleState { key, position }); self.scroll = self.toc[group[position]].line.min(self.max_scroll()); } + + pub(crate) fn toc_half_page_step(&self) -> usize { + self.toc_list_area + .map(|r| (r.height / 2) as usize) + .unwrap_or(10) + .max(1) + } + + pub(crate) fn can_scroll_toc(&self) -> bool { + self.is_toc_visible() && self.has_toc() + } + + #[cfg(test)] + pub(crate) fn toc_scroll_mode(&self) -> TocScrollMode { + self.toc_scroll_mode + } + + #[cfg(test)] + pub(crate) fn is_toc_scroll_hint_dismissed(&self) -> bool { + self.toc_scroll_hint_dismissed + } + + pub(crate) fn toc_overflows(&self, list_height: u16) -> bool { + self.toc_display_lines.len() > (list_height as usize).saturating_sub(1) + } + + pub(crate) fn is_toc_scroll_hint_visible(&self) -> bool { + if !self.is_toc_visible() || !self.has_toc() || self.toc_scroll_hint_dismissed { + return false; + } + self.toc_list_area + .is_some_and(|area| self.toc_overflows(area.height)) + } + + fn max_toc_scroll_offset(&self, list_height: u16) -> usize { + (self.toc_display_lines.len() + 1).saturating_sub(list_height as usize) + } + + pub(crate) fn toc_scroll_offset(&self, list_height: u16) -> usize { + let max_offset = self.max_toc_scroll_offset(list_height); + let list_height = list_height as usize; + match self.toc_scroll_mode { + TocScrollMode::Manual(offset) => offset.min(max_offset), + TocScrollMode::Auto => { + let Some(active_display_idx) = self.toc_active_display_idx else { + return 0; + }; + let mut offset = 0usize; + if active_display_idx + 1 >= list_height { + offset = active_display_idx + 2 - list_height; + } + offset.min(max_offset) + } + } + } + + pub(crate) fn reset_toc_scroll_mode(&mut self) { + self.toc_scroll_mode = TocScrollMode::Auto; + self.toc_active_pin = None; + } + + pub(crate) fn scroll_toc_down(&mut self, n: usize) { + let current = self.current_toc_offset(); + self.set_toc_manual_offset(current.saturating_add(n)); + } + + pub(crate) fn scroll_toc_up(&mut self, n: usize) { + let current = self.current_toc_offset(); + self.set_toc_manual_offset(current.saturating_sub(n)); + } + + pub(crate) fn focus_next_top_level_toc(&mut self) { + self.cycle_visible_top_level(CycleDirection::Forward); + } + + pub(crate) fn focus_prev_top_level_toc(&mut self) { + self.cycle_visible_top_level(CycleDirection::Backward); + } + + fn cycle_visible_top_level(&mut self, direction: CycleDirection) { + let visible_tops = self.visible_top_level_display_indices(); + if visible_tops.is_empty() { + return; + } + + self.dismiss_toc_scroll_hint_if_visible(); + + let active_position = self.toc_active_display_idx.and_then(|active_idx| { + if !self.is_display_idx_in_toc_window(active_idx) { + return None; + } + visible_tops + .iter() + .rposition(|(d_idx, _)| *d_idx <= active_idx) + }); + + let target_display_idx = match active_position { + Some(position) => { + let len = visible_tops.len(); + let new_position = match direction { + CycleDirection::Forward => (position + 1) % len, + CycleDirection::Backward => (position + len - 1) % len, + }; + visible_tops[new_position].0 + } + None => match direction { + CycleDirection::Forward => visible_tops.first().unwrap().0, + CycleDirection::Backward => visible_tops.last().unwrap().0, + }, + }; + + self.scroll_to_toc_display_line(target_display_idx); + } + + fn visible_top_level_display_indices(&self) -> Vec<(usize, usize)> { + let Some(area) = self.toc_list_area else { + return Vec::new(); + }; + let offset = self.toc_scroll_offset(area.height); + let list_height = area.height as usize; + self.visible_toc_entries() + .enumerate() + .skip(offset) + .take(list_height) + .filter(|(_, (_, _, dl))| *dl == 1) + .map(|(display_idx, (entry_idx, _, _))| (display_idx, entry_idx)) + .collect() + } + + fn is_display_idx_in_toc_window(&self, display_idx: usize) -> bool { + let Some(area) = self.toc_list_area else { + return false; + }; + let offset = self.toc_scroll_offset(area.height); + (offset..offset + area.height as usize).contains(&display_idx) + } + + fn current_toc_offset(&self) -> usize { + let list_height = self.toc_list_area.map(|r| r.height).unwrap_or(0); + self.toc_scroll_offset(list_height) + } + + fn set_toc_manual_offset(&mut self, offset: usize) { + self.dismiss_toc_scroll_hint_if_visible(); + let list_height = self.toc_list_area.map(|r| r.height).unwrap_or(0); + let max_offset = self.max_toc_scroll_offset(list_height); + self.toc_scroll_mode = TocScrollMode::Manual(offset.min(max_offset)); + self.hovered_toc_idx = None; + } + + fn dismiss_toc_scroll_hint_if_visible(&mut self) { + if self.is_toc_scroll_hint_visible() { + self.toc_scroll_hint_dismissed = true; + } + } } diff --git a/src/app/search.rs b/src/app/search.rs index e76209e..2e6d7dc 100644 --- a/src/app/search.rs +++ b/src/app/search.rs @@ -83,6 +83,7 @@ impl App { self.search.matches = search_matches; self.search.idx = 0; if let Some(&f) = self.search.matches.first() { + self.reset_toc_scroll_mode(); self.scroll = f.min(self.max_scroll()); } } @@ -164,6 +165,7 @@ impl App { return; } self.reset_numkey_state(); + self.reset_toc_scroll_mode(); self.search.idx = (self.search.idx + 1) % self.search.matches.len(); self.scroll = self.search.matches[self.search.idx].min(self.max_scroll()); } @@ -173,6 +175,7 @@ impl App { return; } self.reset_numkey_state(); + self.reset_toc_scroll_mode(); if self.search.idx == 0 { self.search.idx = self.search.matches.len() - 1; } else { diff --git a/src/render/popup.rs b/src/render/popup.rs index 5b0fe6f..e82c26a 100644 --- a/src/render/popup.rs +++ b/src/render/popup.rs @@ -77,7 +77,7 @@ pub(super) fn popup_footer_line(segments: &[&'static str], bg: Color) -> Line<'s pub(super) fn render_help_popup(f: &mut Frame, _app: &App) { let theme = app_theme(); - let area = centered_rect(53, 25, f.area()); + let area = centered_rect(53, 26, f.area()); let select_hint = crate::editor::selection_modifier_label(&crate::editor::detect_terminal_emulator()); @@ -138,6 +138,10 @@ pub(super) fn render_help_popup(f: &mut Frame, _app: &App) { Span::styled(format!("{select_hint:<12}"), key_style), Span::styled("slct text", text_style), ]), + Line::from(vec![ + Span::styled("J/K, U/D ", key_style), + Span::styled("navigate toc", text_style), + ]), Line::from(""), Line::from(vec![ Span::styled("Search ", section_style), diff --git a/src/render/status.rs b/src/render/status.rs index da1321a..b4b5dfb 100644 --- a/src/render/status.rs +++ b/src/render/status.rs @@ -353,6 +353,16 @@ pub(crate) fn build_status_bar(app: &App, pct: u16) -> Vec> { return join_span_sections(vec![left], outer_separator); } + if app.is_toc_scroll_hint_visible() { + let theme = app_theme(); + let mut left = status_brand_section(); + left.push(Span::styled( + " Navigate with shift+j/k and shift+u/d ", + Style::default().fg(theme.ui.status_warning_fg).bg(bar_bg), + )); + return join_span_sections(vec![left], outer_separator); + } + let mut left_section = status_brand_section(); left_section.extend(status_filename_section(app.filename())); diff --git a/src/render/toc.rs b/src/render/toc.rs index a109bb8..3fee866 100644 --- a/src/render/toc.rs +++ b/src/render/toc.rs @@ -35,6 +35,7 @@ pub(super) fn render_toc_panel(f: &mut Frame, app: &mut App, area: Rect) { toc_chunks[0], ); + let scroll_offset = app.toc_scroll_offset(toc_chunks[1].height); let mut lines: Vec> = app.toc_display_lines().to_vec(); if let Some(display_idx) = app.hovered_toc_idx { let is_active = app.toc_display_entries().get(display_idx).copied() == app.toc_active_idx; @@ -44,8 +45,14 @@ pub(super) fn render_toc_panel(f: &mut Frame, app: &mut App, area: Rect) { } } } + let padding_width = toc_chunks[1].width.saturating_sub(1) as usize; + lines.push(Line::from(Span::styled( + " ".repeat(padding_width), + Style::default().bg(theme.ui.toc_bg), + ))); f.render_widget( Paragraph::new(lines) + .scroll((scroll_offset as u16, 0)) .style(Style::default().bg(theme.ui.toc_bg)) .block( Block::default() diff --git a/src/runtime/keyboard.rs b/src/runtime/keyboard.rs index 0601d26..6a325c0 100644 --- a/src/runtime/keyboard.rs +++ b/src/runtime/keyboard.rs @@ -280,6 +280,14 @@ pub(super) fn handle_key_event( KeyCode::Char('u') | KeyCode::PageUp => app.scroll_up(20), KeyCode::Char('g') | KeyCode::Home => app.scroll_top(), KeyCode::Char('G') | KeyCode::End => app.scroll_bottom(), + KeyCode::Char('J') if app.can_scroll_toc() => app.focus_next_top_level_toc(), + KeyCode::Char('K') if app.can_scroll_toc() => app.focus_prev_top_level_toc(), + KeyCode::Char('D') if app.can_scroll_toc() => { + app.scroll_toc_down(app.toc_half_page_step()); + } + KeyCode::Char('U') if app.can_scroll_toc() => { + app.scroll_toc_up(app.toc_half_page_step()); + } KeyCode::Char('t') => app.toggle_toc(), KeyCode::Char('T') => { app.open_theme_picker(); diff --git a/src/runtime/mouse.rs b/src/runtime/mouse.rs index 06aa0d7..2a39662 100644 --- a/src/runtime/mouse.rs +++ b/src/runtime/mouse.rs @@ -69,6 +69,10 @@ pub(super) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> bool { } else { match mouse.kind { MouseEventKind::ScrollUp => { + if mouse_in_toc_area(app, mouse.column, mouse.row) { + app.scroll_toc_up(super::MOUSE_SCROLL_STEP); + return true; + } app.exit_code_select_mode(); app.scroll_up(super::MOUSE_SCROLL_STEP); app.hovered_link = None; @@ -76,6 +80,10 @@ pub(super) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> bool { true } MouseEventKind::ScrollDown => { + if mouse_in_toc_area(app, mouse.column, mouse.row) { + app.scroll_toc_down(super::MOUSE_SCROLL_STEP); + return true; + } app.exit_code_select_mode(); app.scroll_down(super::MOUSE_SCROLL_STEP); app.hovered_link = None; @@ -93,9 +101,11 @@ pub(super) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> bool { app.last_click = Some((mouse.column, mouse.row, now)); if let Some(area) = app.toc_list_area { + let scroll_offset = app.toc_scroll_offset(area.height); if let Some(display_idx) = toc_display_index_at( area, app.toc_display_entries().len(), + scroll_offset, mouse.column, mouse.row, ) { @@ -216,9 +226,11 @@ pub(super) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> bool { } let new_toc_hover = app.toc_list_area.and_then(|area| { + let scroll_offset = app.toc_scroll_offset(area.height); toc_display_index_at( area, app.toc_display_entries().len(), + scroll_offset, mouse.column, mouse.row, ) @@ -325,7 +337,18 @@ fn try_open_editor( const TOC_RIGHT_BORDER_WIDTH: u16 = 1; -fn toc_display_index_at(area: Rect, entries_len: usize, col: u16, row: u16) -> Option { +fn mouse_in_toc_area(app: &App, col: u16, row: u16) -> bool { + app.toc_list_area + .is_some_and(|area| is_in_rect(area, col, row)) +} + +fn toc_display_index_at( + area: Rect, + entries_len: usize, + scroll_offset: usize, + col: u16, + row: u16, +) -> Option { let inner = Rect { width: area.width.saturating_sub(TOC_RIGHT_BORDER_WIDTH), ..area @@ -333,7 +356,7 @@ fn toc_display_index_at(area: Rect, entries_len: usize, col: u16, row: u16) -> O if !is_in_rect(inner, col, row) { return None; } - let display_idx = (row - area.y) as usize; + let display_idx = (row - area.y) as usize + scroll_offset; (display_idx < entries_len).then_some(display_idx) } diff --git a/src/tests/toc.rs b/src/tests/toc.rs index 9d21602..c4c437b 100644 --- a/src/tests/toc.rs +++ b/src/tests/toc.rs @@ -1,5 +1,5 @@ use super::{test_assets, test_md_theme}; -use crate::app::App; +use crate::app::{App, TocScrollMode}; use crate::markdown::parse_markdown; use crate::*; use ratatui::layout::Rect; @@ -207,3 +207,362 @@ fn normalize_keeps_top_three_paliers() { vec![2, 3, 4] ); } + +fn make_app_with_overflowing_toc() -> App { + let entries: Vec<(u8, usize)> = (0..20).map(|i| (2, i * 3)).collect(); + let mut app = make_app_with_toc(100, 15, toc(&entries)); + // Simulate a rendered TOC panel smaller than the entry count. + app.toc_list_area = Some(Rect::new(0, 0, 30, 10)); + app.refresh_toc_cache(); + app +} + +#[test] +fn toc_scroll_mode_default_is_auto() { + let app = make_app_with_toc(10, 15, toc(&[(2, 0)])); + assert_eq!(app.toc_scroll_mode(), TocScrollMode::Auto); + assert!(!app.is_toc_scroll_hint_dismissed()); +} + +#[test] +fn toc_overflows_true_when_entries_exceed_list_height() { + let app = make_app_with_overflowing_toc(); + assert!(app.toc_overflows(10)); + assert!(!app.toc_overflows(50)); +} + +#[test] +fn scroll_toc_down_switches_to_manual_mode_and_dismisses_hint() { + let mut app = make_app_with_overflowing_toc(); + app.toggle_toc(); + app.scroll_toc_down(3); + assert_eq!(app.toc_scroll_mode(), TocScrollMode::Manual(3)); + assert!(app.is_toc_scroll_hint_dismissed()); + assert_eq!(app.hovered_toc_idx, None); +} + +#[test] +fn scroll_toc_does_not_dismiss_hint_when_warning_not_visible() { + let mut app = make_app_with_overflowing_toc(); + app.scroll_toc_down(3); + assert_eq!(app.toc_scroll_mode(), TocScrollMode::Manual(3)); + assert!(!app.is_toc_scroll_hint_dismissed()); +} + +#[test] +fn scroll_toc_up_saturates_at_zero() { + let mut app = make_app_with_overflowing_toc(); + app.scroll_toc_up(5); + assert_eq!(app.toc_scroll_mode(), TocScrollMode::Manual(0)); +} + +#[test] +fn scroll_toc_down_bounds_to_max_offset() { + let mut app = make_app_with_overflowing_toc(); + let total = app.toc_display_lines().len(); + app.scroll_toc_down(1000); + let max_offset = (total + 1).saturating_sub(10); + assert_eq!(app.toc_scroll_mode(), TocScrollMode::Manual(max_offset)); +} + +#[test] +fn scroll_toc_down_then_up_composes() { + let mut app = make_app_with_overflowing_toc(); + app.scroll_toc_down(5); + app.scroll_toc_up(2); + assert_eq!(app.toc_scroll_mode(), TocScrollMode::Manual(3)); +} + +#[test] +fn content_scroll_resets_toc_scroll_mode_to_auto() { + let mut app = make_app_with_overflowing_toc(); + app.scroll_toc_down(5); + assert!(matches!(app.toc_scroll_mode(), TocScrollMode::Manual(_))); + app.scroll_down(1); + assert_eq!(app.toc_scroll_mode(), TocScrollMode::Auto); +} + +#[test] +fn scroll_to_toc_display_line_preserves_manual_offset() { + let mut app = make_app_with_overflowing_toc(); + app.scroll_toc_down(5); + app.scroll_to_toc_display_line(2); + assert_eq!(app.toc_scroll_mode(), TocScrollMode::Manual(5)); +} + +#[test] +fn click_on_toc_entry_focuses_it_even_when_scroll_clamped_to_max() { + let mut app = make_app_with_toc(50, 15, toc(&[(2, 0), (2, 10), (2, 44), (2, 48)])); + app.scroll_to_toc_display_line(2); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(2)); +} + +#[test] +fn content_scroll_clears_toc_click_pin() { + let mut app = make_app_with_toc(50, 15, toc(&[(2, 0), (2, 10), (2, 44), (2, 48)])); + app.scroll_to_toc_display_line(2); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(2)); + app.scroll_bottom(); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(3)); +} + +#[test] +fn hint_hidden_when_dismissed() { + let mut app = make_app_with_overflowing_toc(); + app.toggle_toc(); + assert!(app.is_toc_visible()); + assert!(app.is_toc_scroll_hint_visible()); + app.scroll_toc_down(1); + assert!(!app.is_toc_scroll_hint_visible()); +} + +#[test] +fn hint_hidden_when_toc_does_not_overflow() { + let mut app = make_app_with_toc(10, 15, toc(&[(2, 0), (2, 5)])); + app.toc_list_area = Some(Rect::new(0, 0, 30, 10)); + app.refresh_toc_cache(); + app.toggle_toc(); + assert!(app.is_toc_visible()); + assert!(!app.is_toc_scroll_hint_visible()); +} + +#[test] +fn hint_hidden_when_toc_not_visible() { + let app = make_app_with_overflowing_toc(); + assert!(!app.is_toc_visible()); + assert!(!app.is_toc_scroll_hint_visible()); +} + +#[test] +fn toc_scroll_offset_auto_keeps_active_visible() { + let mut app = make_app_with_overflowing_toc(); + app.scroll_bottom(); + app.refresh_toc_cache(); + let offset = app.toc_scroll_offset(10); + let total = app.toc_display_lines().len(); + let active_display_idx = app.toc_active_display_idx.unwrap(); + assert!(active_display_idx < offset + 10); + assert!(offset <= (total + 1).saturating_sub(10)); +} + +#[test] +fn toc_scroll_offset_auto_reserves_padding_row_when_active_is_last() { + let mut app = make_app_with_overflowing_toc(); + app.scroll_bottom(); + app.refresh_toc_cache(); + let list_height = 10; + let offset = app.toc_scroll_offset(list_height); + let total = app.toc_display_lines().len(); + assert!(offset + list_height as usize > total); +} + +#[test] +fn toc_scroll_offset_manual_returns_stored_offset_bounded() { + let mut app = make_app_with_overflowing_toc(); + app.scroll_toc_down(4); + assert_eq!(app.toc_scroll_offset(10), 4); + // Simulate terminal resize: with a taller window, offset should clamp. + let total = app.toc_display_lines().len(); + assert!(app.toc_scroll_offset(1000) <= total); +} + +fn make_app_with_top_and_sub_toc() -> App { + let entries = vec![ + (1u8, 0usize), + (2, 2), + (2, 4), + (1, 6), + (2, 8), + (1, 10), + (2, 12), + (2, 14), + (1, 16), + (2, 18), + ]; + let mut app = make_app_with_toc(50, 15, toc(&entries)); + app.toc_list_area = Some(Rect::new(0, 0, 30, 10)); + app.toggle_toc(); + app.refresh_toc_cache(); + app +} + +#[test] +fn focus_next_top_level_cycles_within_visible() { + let mut app = make_app_with_top_and_sub_toc(); + app.scroll_to_toc_display_line(0); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(0)); + + app.focus_next_top_level_toc(); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(3)); + + app.focus_next_top_level_toc(); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(5)); +} + +#[test] +fn focus_next_top_level_wraps_around_visible() { + let mut app = make_app_with_top_and_sub_toc(); + app.scroll_to_toc_display_line(8); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(8)); + + app.focus_next_top_level_toc(); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(0)); +} + +#[test] +fn focus_prev_top_level_wraps_around_visible() { + let mut app = make_app_with_top_and_sub_toc(); + app.scroll_to_toc_display_line(0); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(0)); + + app.focus_prev_top_level_toc(); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(8)); +} + +#[test] +fn focus_from_sub_entry_uses_top_level_ancestor() { + let mut app = make_app_with_top_and_sub_toc(); + app.scroll_to_toc_display_line(4); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(4)); + + app.focus_next_top_level_toc(); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(5)); +} + +fn make_app_with_overflowing_top_sub_toc() -> App { + let entries = vec![ + (1u8, 0usize), + (2, 2), + (1, 4), + (2, 6), + (1, 8), + (2, 10), + (1, 12), + (2, 14), + (1, 16), + (2, 18), + ]; + let mut app = make_app_with_toc(50, 15, toc(&entries)); + app.toc_list_area = Some(Rect::new(0, 0, 30, 4)); + app.toggle_toc(); + app.refresh_toc_cache(); + app +} + +#[test] +fn focus_next_when_active_out_of_window_jumps_to_first_visible() { + let mut app = make_app_with_overflowing_top_sub_toc(); + app.scroll_to_toc_display_line(0); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(0)); + app.scroll_toc_down(6); + app.refresh_toc_cache(); + let offset = app.toc_scroll_offset(4); + assert!(offset > 0); + + app.focus_next_top_level_toc(); + app.refresh_toc_cache(); + let target = app.toc_active_idx.unwrap(); + let target_display_idx = app + .toc_display_entries() + .iter() + .position(|&i| i == target) + .unwrap(); + assert!(target_display_idx >= offset); + assert!(target_display_idx < offset + 4); +} + +#[test] +fn focus_prev_when_active_out_of_window_jumps_to_last_visible() { + let mut app = make_app_with_overflowing_top_sub_toc(); + app.scroll_to_toc_display_line(0); + app.refresh_toc_cache(); + app.scroll_toc_down(6); + app.refresh_toc_cache(); + let offset = app.toc_scroll_offset(4); + + app.focus_prev_top_level_toc(); + app.refresh_toc_cache(); + let target = app.toc_active_idx.unwrap(); + let target_display_idx = app + .toc_display_entries() + .iter() + .position(|&i| i == target) + .unwrap(); + assert!(target_display_idx >= offset); + assert!(target_display_idx < offset + 4); +} + +#[test] +fn focus_top_level_preserves_toc_scroll_offset() { + let mut app = make_app_with_overflowing_top_sub_toc(); + app.scroll_toc_down(2); + let before = app.toc_scroll_offset(4); + assert_eq!(before, 2); + app.focus_next_top_level_toc(); + let after = app.toc_scroll_offset(4); + assert_eq!(after, before); +} + +#[test] +fn focus_top_level_noop_when_no_level_1_visible() { + let mut app = make_app_with_toc( + 50, + 15, + toc(&[ + (1, 0), + (2, 2), + (2, 4), + (2, 6), + (2, 8), + (2, 10), + (1, 12), + (2, 14), + (2, 16), + (2, 18), + ]), + ); + app.toc_list_area = Some(Rect::new(0, 0, 30, 3)); + app.toggle_toc(); + app.refresh_toc_cache(); + app.scroll_toc_down(2); + app.refresh_toc_cache(); + let scroll_before = app.scroll(); + let mode_before = app.toc_scroll_mode(); + + app.focus_next_top_level_toc(); + assert_eq!(app.scroll(), scroll_before); + assert_eq!(app.toc_scroll_mode(), mode_before); +} + +#[test] +fn focus_top_level_ignores_reverse_mode() { + let mut app = make_app_with_top_and_sub_toc(); + app.toggle_reverse_mode(); + app.scroll_to_toc_display_line(0); + app.refresh_toc_cache(); + app.focus_next_top_level_toc(); + app.refresh_toc_cache(); + assert_eq!(app.toc_active_idx, Some(3)); +} + +#[test] +fn focus_top_level_dismisses_hint_when_visible() { + let mut app = make_app_with_overflowing_top_sub_toc(); + assert!(app.is_toc_scroll_hint_visible()); + app.focus_next_top_level_toc(); + assert!(!app.is_toc_scroll_hint_visible()); + assert!(app.is_toc_scroll_hint_dismissed()); +}