1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
//! Heading-ancestor chain for the cursor's current position.
//!
//! Used by the status bar to render a breadcrumb like
//! `notes.md › Checkpoint 1 › Item 1` so the user always sees where in
//! the document tree the cursor sits. Complements the section picker
//! (`crate::ui::section_picker`) which lets them jump anywhere in the
//! same tree.
//!
//! Mirrors the heading-walk shape used by `App::open_section_picker`
//! (`src/app/section_jump.rs`) but lives one layer down so the UI layer
//! can call it without depending on `app`.
use pulldown_cmark::HeadingLevel;
use crate::editor::EditorState;
use crate::markdown::{ast::heading_plain_text, Block};
impl EditorState {
/// The chain of headings whose scope contains the cursor, in
/// document order (shallowest → deepest). Empty when no heading
/// precedes the cursor or the document has no headings at all.
///
/// Walks `parsed.blocks` once, collecting all headings at or before
/// the cursor's buffer line, then keeps only the strictly-decreasing
/// suffix from the end — that's the deepest enclosing heading plus
/// each of its true ancestors. Skips intermediate sibling headings
/// the cursor isn't actually under.
///
/// Cheap enough to call on every status-bar redraw: O(blocks) with
/// two rope lookups per heading, and `parsed` is stable between
/// buffer mutations.
pub fn cursor_section_chain(&self) -> Vec<String> {
let cursor_line = self.buffer.char_to_line(self.cursor.offset);
self.section_chain_for_buffer_line(cursor_line)
}
/// The heading-ancestor chain anchored on the top of the viewport
/// rather than the cursor. Used in Preview mode, where the cursor
/// is hidden and doesn't track the scroll position: the top visible
/// rendered line is mapped back to a source byte (via the
/// `source_map`) and then to a buffer line, so the breadcrumb stays
/// in sync with what the user is reading. A scroll position past the
/// last rendered line (which shouldn't normally happen — the viewport
/// clamps it) is pinned to the last line so the breadcrumb tracks the
/// deepest visible section rather than resetting to the document root.
/// Falls back to the first buffer line when there is no source mapping
/// at all (e.g. an empty document).
pub fn scroll_section_chain(&self) -> Vec<String> {
let map = &self.parsed.source_map;
let last_line = map.rendered_line_count().saturating_sub(1);
let rendered_line = self.scroll.min(last_line);
let line = map
.original_byte_for_rendered_line(rendered_line)
.map(|byte| self.buffer.byte_to_line(byte))
.unwrap_or(0);
self.section_chain_for_buffer_line(line)
}
/// Shared implementation behind [`Self::cursor_section_chain`] and
/// [`Self::scroll_section_chain`]: collect the heading chain enclosing
/// `anchor_line` (a buffer line index).
fn section_chain_for_buffer_line(&self, anchor_line: usize) -> Vec<String> {
let mut at_or_before: Vec<(HeadingLevel, String)> = Vec::new();
for (block_idx, block) in self.parsed.blocks.iter().enumerate() {
let Block::Heading { level, inlines } = block else {
continue;
};
let Some(range) = self.parsed.real_ranges.get(block_idx) else {
continue;
};
let buffer_line = self.buffer.byte_to_line(range.start);
if buffer_line > anchor_line {
// Blocks are in document order — every later heading is
// past the anchor line.
break;
}
at_or_before.push((*level, heading_plain_text(inlines)));
}
// Walk back from the deepest heading at-or-before, taking only
// strictly-shallower ancestors. This skips siblings the cursor
// isn't actually under (e.g. an earlier H2 in a different
// section won't show up as an "ancestor" of an H3 under a later
// H2).
let mut chain: Vec<String> = Vec::new();
let mut shallowest_level = usize::MAX;
for (level, text) in at_or_before.iter().rev() {
let lvl = *level as usize;
if lvl < shallowest_level {
chain.push(text.clone());
shallowest_level = lvl;
}
if shallowest_level == 1 {
break;
}
}
chain.reverse();
chain
}
}
#[cfg(test)]
mod tests {
use crate::document::Buffer;
use crate::editor::EditorState;
fn state_from(src: &str, cursor_offset: usize) -> EditorState {
let theme = Box::leak(Box::new(crate::config::Theme::default()));
let mut st = EditorState::new(Buffer::from_str(src), theme);
st.cursor.offset = cursor_offset;
st
}
#[test]
fn empty_when_no_headings() {
let st = state_from("just a paragraph\n", 0);
assert!(st.cursor_section_chain().is_empty());
}
#[test]
fn empty_when_cursor_precedes_first_heading() {
// Cursor at offset 0 — before the heading on line 1.
let st = state_from("prelude\n\n# Top\n", 0);
assert!(st.cursor_section_chain().is_empty());
}
#[test]
fn returns_only_heading_when_under_single_h1() {
// Cursor in the body line under "# Top".
let src = "# Top\n\nbody text\n";
let cursor = src.find("body").unwrap();
let st = state_from(src, cursor);
assert_eq!(st.cursor_section_chain(), vec!["Top".to_string()]);
}
#[test]
fn returns_full_chain_in_document_order() {
let src = "# A\n\n## B\n\n### C\n\nbody\n";
let cursor = src.find("body").unwrap();
let st = state_from(src, cursor);
assert_eq!(
st.cursor_section_chain(),
vec!["A".to_string(), "B".to_string(), "C".to_string()]
);
}
#[test]
fn skips_earlier_sibling_at_same_level() {
// The cursor is under "A2 > B" — the earlier "## A1" must not
// appear in the chain.
let src = "# Top\n\n## A1\n\nfirst body\n\n## A2\n\n### B\n\nsecond body\n";
let cursor = src.find("second body").unwrap();
let st = state_from(src, cursor);
assert_eq!(
st.cursor_section_chain(),
vec!["Top".to_string(), "A2".to_string(), "B".to_string()]
);
}
#[test]
fn cursor_on_heading_line_includes_that_heading() {
// Cursor on "## B" itself — that heading IS the section, plus
// its ancestor "# A".
let src = "# A\n\n## B\n\nbody\n";
let cursor = src.find("## B").unwrap();
let st = state_from(src, cursor);
assert_eq!(
st.cursor_section_chain(),
vec!["A".to_string(), "B".to_string()]
);
}
#[test]
fn skipped_heading_levels_are_handled() {
// H1 → H3 (no H2) — the chain has two entries, not three.
let src = "# Top\n\n### Deep\n\nbody\n";
let cursor = src.find("body").unwrap();
let st = state_from(src, cursor);
assert_eq!(
st.cursor_section_chain(),
vec!["Top".to_string(), "Deep".to_string()]
);
}
/// In Preview, the breadcrumb follows the scroll position, not the
/// (frozen) cursor. Scrolling the top of the viewport into a deeper
/// section updates the chain even though the cursor stays at offset 0.
#[test]
fn scroll_chain_follows_viewport_top() {
let src = "# A\n\n## B\n\nbody\n";
// Cursor parked on the top heading line — its chain is just "A".
let st = state_from(src, 0);
assert_eq!(st.cursor_section_chain(), vec!["A".to_string()]);
// Scroll so the top visible rendered line is the "body" block.
let body_byte = src.find("body").unwrap();
let body_line = st
.parsed
.source_map
.rendered_lines_for_byte(body_byte)
.start;
let mut st = st;
st.scroll = body_line;
assert_eq!(
st.scroll_section_chain(),
vec!["A".to_string(), "B".to_string()]
);
}
#[test]
fn formatted_heading_text_is_flattened() {
let src = "## **Bold** and `code`\n\nbody\n";
let cursor = src.find("body").unwrap();
let st = state_from(src, cursor);
assert_eq!(st.cursor_section_chain(), vec!["Bold and code".to_string()]);
}
}