1use std::collections::HashMap;
12
13use ratatui::buffer::Buffer;
14use ratatui::layout::{Constraint, Direction, Layout, Rect};
15use ratatui::style::{Modifier, Style};
16use ratatui::text::{Line, Span, Text};
17use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Widget, Wrap};
18
19use crate::error::Result;
20use crate::index::Index;
21use crate::render;
22use crate::search::SearchIndex;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Focus {
27 Index,
29 Content,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Mode {
36 Normal,
38 Search(String),
40}
41
42pub struct DocsBrowser {
45 index: Index,
46 pages: HashMap<String, String>,
47 search: SearchIndex,
48 list_state: ListState,
49 pub(crate) focus: Focus,
50 pub(crate) mode: Mode,
51 flat_entries: Vec<FlatEntry>,
53 current_body: String,
56 quit_requested: bool,
58}
59
60struct FlatEntry {
61 section: String,
62 path: String,
63 title: String,
64}
65
66impl DocsBrowser {
67 pub fn new(index: Index, pages: HashMap<String, String>) -> Result<Self> {
74 let flat_entries: Vec<FlatEntry> = index
75 .entries()
76 .map(|(section, entry)| FlatEntry {
77 section: section.to_string(),
78 path: entry.path.clone(),
79 title: entry.title.clone(),
80 })
81 .collect();
82 let search_input: Vec<(String, String, String)> = flat_entries
83 .iter()
84 .map(|e| {
85 (e.path.clone(), e.title.clone(), pages.get(&e.path).cloned().unwrap_or_default())
86 })
87 .collect();
88 let search = SearchIndex::build(&search_input)?;
89 let mut list_state = ListState::default();
90 if !flat_entries.is_empty() {
91 list_state.select(Some(0));
92 }
93 let current_body =
94 flat_entries.first().and_then(|e| pages.get(&e.path)).cloned().unwrap_or_default();
95 Ok(Self {
96 index,
97 pages,
98 search,
99 list_state,
100 focus: Focus::Index,
101 mode: Mode::Normal,
102 flat_entries,
103 current_body,
104 quit_requested: false,
105 })
106 }
107
108 #[must_use]
110 pub const fn quit_requested(&self) -> bool {
111 self.quit_requested
112 }
113
114 #[must_use]
117 pub fn selected_path(&self) -> Option<&str> {
118 let idx = self.list_state.selected()?;
119 self.flat_entries.get(idx).map(|e| e.path.as_str())
120 }
121
122 #[must_use]
125 pub const fn search(&self) -> &SearchIndex {
126 &self.search
127 }
128
129 pub fn move_selection(&mut self, delta: isize) {
132 if self.flat_entries.is_empty() {
133 return;
134 }
135 let current = self.list_state.selected().unwrap_or(0);
136 let new = if delta >= 0 {
137 current.saturating_add(delta.unsigned_abs()).min(self.flat_entries.len() - 1)
138 } else {
139 current.saturating_sub(delta.unsigned_abs())
140 };
141 self.list_state.select(Some(new));
142 }
143
144 pub fn open_selected(&mut self) {
146 if let Some(path) = self.selected_path() {
147 self.current_body = self.pages.get(path).cloned().unwrap_or_default();
148 }
149 }
150
151 pub const fn request_quit(&mut self) {
154 self.quit_requested = true;
155 }
156
157 pub fn handle_key(&mut self, key: KeyCode) {
161 match (&self.mode, key) {
162 (Mode::Normal, KeyCode::Char('q')) => self.request_quit(),
163 (Mode::Normal, KeyCode::Char('j') | KeyCode::Down) => self.move_selection(1),
164 (Mode::Normal, KeyCode::Char('k') | KeyCode::Up) => self.move_selection(-1),
165 (Mode::Normal, KeyCode::Enter) => self.open_selected(),
166 (Mode::Normal, KeyCode::Tab) => {
167 self.focus = match self.focus {
168 Focus::Index => Focus::Content,
169 Focus::Content => Focus::Index,
170 };
171 }
172 (Mode::Normal, KeyCode::Char('/')) => {
173 self.mode = Mode::Search(String::new());
174 }
175 (Mode::Search(_), KeyCode::Esc) => {
176 self.mode = Mode::Normal;
177 }
178 (Mode::Search(buf), KeyCode::Char(c)) => {
179 let mut new = buf.clone();
180 new.push(c);
181 self.mode = Mode::Search(new);
182 }
183 (Mode::Search(buf), KeyCode::Backspace) => {
184 let mut new = buf.clone();
185 new.pop();
186 self.mode = Mode::Search(new);
187 }
188 (Mode::Search(buf), KeyCode::Enter) => {
189 let top = self.search.title_search(buf).into_iter().next();
191 if let Some(hit) = top {
192 if let Some(idx) = self.flat_entries.iter().position(|e| e.path == hit.path) {
193 self.list_state.select(Some(idx));
194 self.open_selected();
195 }
196 }
197 self.mode = Mode::Normal;
198 }
199 _ => {}
200 }
201 }
202
203 pub fn render(&mut self, area: Rect, buf: &mut Buffer) {
206 let horizontal = Layout::default()
207 .direction(Direction::Horizontal)
208 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
209 .split(area);
210
211 self.render_index(horizontal[0], buf);
212 self.render_content(horizontal[1], buf);
213 }
214
215 fn render_index(&mut self, area: Rect, buf: &mut Buffer) {
216 let items: Vec<ListItem<'_>> = self
217 .flat_entries
218 .iter()
219 .map(|entry| {
220 ListItem::new(Line::from(vec![
221 Span::styled(
222 format!("{} ", entry.section),
223 Style::default().add_modifier(Modifier::DIM),
224 ),
225 Span::raw(entry.title.clone()),
226 ]))
227 })
228 .collect();
229 let border_style = if self.focus == Focus::Index {
230 Style::default().add_modifier(Modifier::BOLD)
231 } else {
232 Style::default()
233 };
234 let mut list_state = self.list_state;
235 let list = List::new(items)
236 .block(
237 Block::default()
238 .borders(Borders::ALL)
239 .title(self.index.title.clone())
240 .border_style(border_style),
241 )
242 .highlight_symbol("▶ ")
243 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
244 ratatui::widgets::StatefulWidget::render(list, area, buf, &mut list_state);
245 self.list_state = list_state;
247 }
248
249 fn render_content(&self, area: Rect, buf: &mut Buffer) {
250 let body_text: Text<'static> = render::to_ratatui_text(&self.current_body)
251 .map(render::text_into_owned)
252 .unwrap_or_default();
253 let title = if let Mode::Search(query) = &self.mode {
254 format!("search: {query}")
255 } else {
256 self.selected_path().unwrap_or("").to_string()
257 };
258 let border_style = if self.focus == Focus::Content {
259 Style::default().add_modifier(Modifier::BOLD)
260 } else {
261 Style::default()
262 };
263 let block = Block::default().borders(Borders::ALL).title(title).border_style(border_style);
264 Paragraph::new(body_text).wrap(Wrap { trim: false }).block(block).render(area, buf);
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum KeyCode {
272 Char(char),
274 Up,
276 Down,
278 Enter,
280 Tab,
282 Backspace,
284 Esc,
286}
287
288#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::index::{IndexEntry, IndexSection};
296
297 fn browser() -> DocsBrowser {
298 let index = Index {
299 title: "Docs".into(),
300 sections: vec![IndexSection {
301 title: "Start".into(),
302 pages: vec![
303 IndexEntry { path: "a.md".into(), title: "Alpha".into() },
304 IndexEntry { path: "b.md".into(), title: "Beta".into() },
305 IndexEntry { path: "c.md".into(), title: "Gamma".into() },
306 ],
307 }],
308 };
309 let mut pages = HashMap::new();
310 pages.insert("a.md".into(), "# Alpha\n\nA content".into());
311 pages.insert("b.md".into(), "# Beta\n\nB content".into());
312 pages.insert("c.md".into(), "# Gamma\n\nC content".into());
313 DocsBrowser::new(index, pages).expect("build")
314 }
315
316 #[test]
317 fn first_entry_selected_by_default() {
318 let b = browser();
319 assert_eq!(b.selected_path(), Some("a.md"));
320 }
321
322 #[test]
323 fn down_arrow_advances_selection() {
324 let mut b = browser();
325 b.handle_key(KeyCode::Down);
326 assert_eq!(b.selected_path(), Some("b.md"));
327 }
328
329 #[test]
330 fn up_arrow_at_top_clamps() {
331 let mut b = browser();
332 b.handle_key(KeyCode::Up);
333 assert_eq!(b.selected_path(), Some("a.md"));
334 }
335
336 #[test]
337 fn j_and_k_move_selection() {
338 let mut b = browser();
339 b.handle_key(KeyCode::Char('j'));
340 b.handle_key(KeyCode::Char('j'));
341 assert_eq!(b.selected_path(), Some("c.md"));
342 b.handle_key(KeyCode::Char('k'));
343 assert_eq!(b.selected_path(), Some("b.md"));
344 }
345
346 #[test]
347 fn q_requests_quit() {
348 let mut b = browser();
349 assert!(!b.quit_requested());
350 b.handle_key(KeyCode::Char('q'));
351 assert!(b.quit_requested());
352 }
353
354 #[test]
355 fn tab_toggles_focus() {
356 let mut b = browser();
357 assert_eq!(b.focus, Focus::Index);
358 b.handle_key(KeyCode::Tab);
359 assert_eq!(b.focus, Focus::Content);
360 b.handle_key(KeyCode::Tab);
361 assert_eq!(b.focus, Focus::Index);
362 }
363
364 #[test]
365 fn slash_enters_search_mode() {
366 let mut b = browser();
367 b.handle_key(KeyCode::Char('/'));
368 assert!(matches!(b.mode, Mode::Search(_)));
369 b.handle_key(KeyCode::Esc);
370 assert!(matches!(b.mode, Mode::Normal));
371 }
372
373 #[test]
374 fn search_mode_typing_accumulates_buffer() {
375 let mut b = browser();
376 b.handle_key(KeyCode::Char('/'));
377 b.handle_key(KeyCode::Char('b'));
378 b.handle_key(KeyCode::Char('e'));
379 match &b.mode {
380 Mode::Search(buf) => assert_eq!(buf, "be"),
381 other @ Mode::Normal => panic!("expected Search, got {other:?}"),
382 }
383 }
384
385 #[test]
386 fn search_enter_jumps_to_top_hit() {
387 let mut b = browser();
388 b.handle_key(KeyCode::Char('/'));
389 b.handle_key(KeyCode::Char('g'));
390 b.handle_key(KeyCode::Char('a'));
391 b.handle_key(KeyCode::Char('m'));
392 b.handle_key(KeyCode::Enter);
393 assert_eq!(b.selected_path(), Some("c.md"));
394 assert!(matches!(b.mode, Mode::Normal));
395 }
396
397 #[test]
398 fn render_fills_the_buffer() {
399 use ratatui::buffer::Buffer;
400 let mut b = browser();
401 let area = Rect { x: 0, y: 0, width: 80, height: 24 };
402 let mut buf = Buffer::empty(area);
403 b.render(area, &mut buf);
404 }
407}