Skip to main content

wisp/components/plan_review/
outline_panel.rs

1use super::PlanSection;
2use crate::components::common::{CachedLayer, VerticalCursor};
3use tui::{Component, Event, Frame, KeyCode, Line, Style, Theme, ViewContext, truncate_text};
4
5pub enum OutlinePanelMessage {
6    OpenSelectedAnchor(usize),
7}
8
9pub struct OutlinePanel {
10    sections: Vec<PlanSection>,
11    cursor: VerticalCursor,
12    cached_rows: CachedLayer<u16, Vec<Line>>,
13    focused: bool,
14}
15
16impl OutlinePanel {
17    pub fn new(sections: Vec<PlanSection>) -> Self {
18        Self { sections, cursor: VerticalCursor::new(), cached_rows: CachedLayer::new(), focused: false }
19    }
20
21    pub fn set_focused(&mut self, focused: bool) {
22        self.focused = focused;
23    }
24
25    pub fn selected_anchor_line_no(&self) -> Option<usize> {
26        self.sections.get(self.cursor.row).map(|section| section.first_line_no)
27    }
28
29    fn move_selected(&mut self, delta: isize) {
30        self.cursor.move_by(delta, self.sections.len().saturating_sub(1));
31    }
32
33    fn move_to_start(&mut self) {
34        self.cursor.move_to_start();
35    }
36
37    fn move_to_end(&mut self) {
38        self.cursor.move_to_end(self.sections.len().saturating_sub(1));
39    }
40
41    fn ensure_visible(&mut self, viewport_height: usize) {
42        if self.sections.is_empty() {
43            self.cursor.scroll = 0;
44            return;
45        }
46
47        self.cursor.row = self.cursor.row.min(self.sections.len().saturating_sub(1));
48        self.cursor.ensure_visible(self.cursor.row, viewport_height);
49    }
50}
51
52impl Component for OutlinePanel {
53    type Message = OutlinePanelMessage;
54
55    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
56        let Event::Key(key) = event else {
57            return None;
58        };
59
60        match key.code {
61            KeyCode::Char('j') | KeyCode::Down => {
62                self.move_selected(1);
63                Some(vec![])
64            }
65            KeyCode::Char('k') | KeyCode::Up => {
66                self.move_selected(-1);
67                Some(vec![])
68            }
69            KeyCode::Char('g') => {
70                self.move_to_start();
71                Some(vec![])
72            }
73            KeyCode::Char('G') => {
74                self.move_to_end();
75                Some(vec![])
76            }
77            KeyCode::Enter => {
78                self.selected_anchor_line_no().map(OutlinePanelMessage::OpenSelectedAnchor).map(|message| vec![message])
79            }
80            _ => None,
81        }
82    }
83
84    fn render(&mut self, ctx: &ViewContext) -> Frame {
85        let theme = &ctx.theme;
86        let width = usize::from(ctx.size.width);
87        let height = usize::from(ctx.size.height);
88        let body_height = height.saturating_sub(1);
89
90        self.ensure_visible(body_height);
91
92        let sections = &self.sections;
93        let cached_section_rows = self.cached_rows.ensure(ctx.size.width, || {
94            sections.iter().map(|section| build_section_row(section, width, theme)).collect()
95        });
96        let cursor_row = self.cursor.row;
97        let scroll = self.cursor.scroll;
98
99        let mut lines = Vec::with_capacity(height);
100
101        let mut header = Line::default();
102        let title_fg = if self.focused { theme.accent() } else { theme.text_primary() };
103        header.push_text(" ");
104        header.push_with_style("Outline", Style::fg(title_fg).bold());
105        header.extend_bg_to_width(width);
106        lines.push(header);
107
108        for row in 0..body_height {
109            let section_index = scroll + row;
110            if let Some(section) = self.sections.get(section_index) {
111                let selected = section_index == cursor_row;
112                if selected {
113                    lines.push(build_selected_row(section, width, theme));
114                } else {
115                    lines.push(cached_section_rows[section_index].clone());
116                }
117            } else {
118                let mut line = Line::default();
119                line.push_text(" ".repeat(width));
120                lines.push(line);
121            }
122        }
123
124        Frame::new(lines)
125    }
126}
127
128fn build_section_row(section: &PlanSection, width: usize, _theme: &Theme) -> Line {
129    build_row_with_style(section, width, "  ", Style::default())
130}
131
132fn build_selected_row(section: &PlanSection, width: usize, theme: &Theme) -> Line {
133    build_row_with_style(section, width, "> ", theme.selected_row_style())
134}
135
136fn build_row_with_style(section: &PlanSection, width: usize, marker: &str, style: Style) -> Line {
137    let indent = "  ".repeat(section.level.saturating_sub(1) as usize);
138    let prefix = format!("{marker}{indent}");
139    let title_width = width.saturating_sub(prefix.chars().count());
140    let title = truncate_text(&section.title, title_width);
141
142    let mut line = Line::default();
143    line.push_with_style(prefix, style);
144    line.push_with_style(title.as_ref(), style);
145    line.extend_bg_to_width(width);
146    line
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use tui::{Event, KeyEvent, KeyModifiers};
153
154    fn section(title: &str, level: u8, first_line_no: usize) -> PlanSection {
155        PlanSection { title: title.to_string(), level, first_line_no }
156    }
157
158    #[tokio::test]
159    async fn navigation_moves_selection_without_emitting_parent_messages() {
160        let mut panel = OutlinePanel::new(vec![section("One", 1, 1), section("Two", 2, 10)]);
161
162        let messages =
163            panel.on_event(&Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE))).await.unwrap();
164
165        assert!(messages.is_empty());
166        assert_eq!(panel.selected_anchor_line_no(), Some(10));
167    }
168
169    #[tokio::test]
170    async fn enter_opens_selected_anchor() {
171        let mut panel = OutlinePanel::new(vec![section("One", 1, 1)]);
172        let messages = panel.on_event(&Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))).await.unwrap();
173
174        assert!(matches!(messages[0], OutlinePanelMessage::OpenSelectedAnchor(1)));
175    }
176}