Skip to main content

rtb_docs/
browser.rs

1//! `DocsBrowser` — two-pane ratatui TUI over an embedded markdown
2//! tree.
3//!
4//! v0.1 ships the state machine + render contract but stops short of
5//! a full event loop. The loop wiring (`crossterm::event::read` +
6//! terminal mode management) lands in the v0.2.x CLI dispatch
7//! follow-up; for now consumers use [`DocsBrowser::render`] to draw
8//! a frame and [`DocsBrowser::handle_key`] to mutate state so tests
9//! and future callers can drive the browser programmatically.
10
11use 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/// Which pane currently has focus.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Focus {
27    /// Left (index) pane.
28    Index,
29    /// Right (content) pane.
30    Content,
31}
32
33/// The input mode — normal navigation or search.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Mode {
36    /// Arrow keys move the selection; `Enter` opens the selected page.
37    Normal,
38    /// Typing filters the index by fuzzy title match.
39    Search(String),
40}
41
42/// Browser state. Hold it across frames; pass it to [`render`] + the
43/// key-event handler.
44pub 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 list of (section-label, entry) pairs for the left pane.
52    flat_entries: Vec<FlatEntry>,
53    /// Currently-rendered right-pane body, in markdown form. Empty
54    /// when no page is selected.
55    current_body: String,
56    /// `true` when the user has requested quit.
57    quit_requested: bool,
58}
59
60struct FlatEntry {
61    section: String,
62    path: String,
63    title: String,
64}
65
66impl DocsBrowser {
67    /// Build the browser from an already-loaded index + pages map.
68    ///
69    /// # Errors
70    ///
71    /// Propagates [`crate::error::DocsError::Search`] from the FTS
72    /// index build.
73    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    /// `true` once the user has pressed `q`.
109    #[must_use]
110    pub const fn quit_requested(&self) -> bool {
111        self.quit_requested
112    }
113
114    /// The currently-selected entry's path, or `None` when the index
115    /// is empty.
116    #[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    /// Borrow the FTS index — useful for tests that want to exercise
123    /// the search side independently of key events.
124    #[must_use]
125    pub const fn search(&self) -> &SearchIndex {
126        &self.search
127    }
128
129    /// Advance the selection by `delta` (positive = down, negative =
130    /// up). Wraps neither end — out-of-range is clamped.
131    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    /// Open the currently-selected page into the right pane.
145    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    /// Request quit — callers exit their event loop on the next
152    /// iteration.
153    pub const fn request_quit(&mut self) {
154        self.quit_requested = true;
155    }
156
157    /// Key-event entry point. Pass the crossterm `KeyEvent`'s `code`.
158    /// Abstracted away from `crossterm::event::KeyCode` so unit tests
159    /// don't need the crossterm types in scope.
160    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                // Open the top hit (if any) and exit search mode.
190                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    /// Render the browser into the given `Rect` on the supplied
204    /// `Buffer`. Call once per frame.
205    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        // Persist any changes the widget made back to the state.
246        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/// Key code subset the browser cares about. Kept independent of
269/// `crossterm::event::KeyCode` so tests don't pull crossterm in.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum KeyCode {
272    /// Literal character.
273    Char(char),
274    /// Up arrow.
275    Up,
276    /// Down arrow.
277    Down,
278    /// Enter / Return.
279    Enter,
280    /// Tab.
281    Tab,
282    /// Backspace.
283    Backspace,
284    /// Escape.
285    Esc,
286}
287
288// ---------------------------------------------------------------------
289// Tests
290// ---------------------------------------------------------------------
291
292#[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        // Smoke: no panic. Detailed rendering assertions live in a
405        // follow-up PR using TestBackend once the extras layer lands.
406    }
407}