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