Skip to main content

wisp/components/plan_review/
plan_panel.rs

1use super::PlanDocument;
2use crate::components::common::{AnchoredSurfaceBuilder, CachedLayer};
3use crate::components::review_comments::{
4    AnchorIndex, AnchoredRows, CommentAnchor, KeyOutcome, Navigation, ReviewComment, ReviewSurface, ReviewSurfaceEvent,
5};
6use tui::{
7    Component, Event, Frame, Line, MouseEventKind, SourceMarkdownLine, Style, ViewContext, digit_count,
8    render_markdown_source_lines,
9};
10
11const PAGE_SIZE: usize = 10;
12
13pub enum PlanPanelMessage {}
14
15pub(crate) type SourceLineAnchor = CommentAnchor<usize>;
16
17pub struct PlanPanel {
18    document: PlanDocument,
19    surface: ReviewSurface<usize>,
20    queued_comments: Vec<ReviewComment<usize>>,
21    heading_lines: Vec<usize>,
22    cached_markdown: CachedLayer<usize, PlanMarkdown>,
23    cached_surface: CachedLayer<u16, PlanSurface>,
24}
25
26struct PlanMarkdown {
27    rendered_lines: Vec<SourceMarkdownLine>,
28}
29
30struct PlanSurface {
31    surface: AnchoredRows<usize>,
32    line_anchors: AnchorIndex<usize>,
33}
34
35impl PlanPanel {
36    pub fn new(document: PlanDocument) -> Self {
37        let heading_lines = document.outline.iter().map(|section| section.first_line_no).collect();
38        Self {
39            document,
40            surface: ReviewSurface::new(),
41            queued_comments: Vec::new(),
42            heading_lines,
43            cached_markdown: CachedLayer::new(),
44            cached_surface: CachedLayer::new(),
45        }
46    }
47
48    pub fn document(&self) -> &PlanDocument {
49        &self.document
50    }
51
52    pub fn is_in_comment_mode(&self) -> bool {
53        self.surface.is_in_comment_mode()
54    }
55
56    pub fn current_source_line_no(&self) -> usize {
57        self.current_cursor_source_line_no().unwrap_or(1)
58    }
59
60    pub fn set_cursor_source_line_no(&mut self, line_no: usize) {
61        let Some(cached) = self.cached_surface.get() else {
62            return;
63        };
64
65        if let Some(row) = cached.surface.start_row_for_anchor(CommentAnchor(line_no)) {
66            self.surface.cursor_mut().row = row;
67            return;
68        }
69
70        if let Some(nearest) =
71            cached.line_anchors.as_slice().iter().rev().copied().find(|CommentAnchor(a)| *a <= line_no)
72            && let Some(row) = cached.surface.start_row_for_anchor(nearest)
73        {
74            self.surface.cursor_mut().row = row;
75        }
76    }
77
78    pub fn jump_next_heading(&mut self) -> bool {
79        let current = self.current_source_line_no();
80        if let Some(next) = self.heading_lines.iter().copied().find(|line_no| *line_no > current) {
81            self.set_cursor_source_line_no(next);
82            return true;
83        }
84        false
85    }
86
87    pub fn jump_prev_heading(&mut self) -> bool {
88        let current = self.current_source_line_no();
89        if let Some(previous) = self.heading_lines.iter().copied().rev().find(|line_no| *line_no < current) {
90            self.set_cursor_source_line_no(previous);
91            return true;
92        }
93        false
94    }
95
96    pub fn undo_last_comment(&mut self) -> bool {
97        self.queued_comments.pop().is_some()
98    }
99
100    pub fn comment_count(&self) -> usize {
101        self.queued_comments.len()
102    }
103
104    pub(crate) fn comments(&self) -> &[ReviewComment<usize>] {
105        &self.queued_comments
106    }
107
108    fn current_cursor_anchor(&self) -> Option<SourceLineAnchor> {
109        self.cached_surface.get().and_then(|cached| self.surface.current_anchor(&cached.surface))
110    }
111
112    fn current_cursor_source_line_no(&self) -> Option<usize> {
113        self.current_cursor_anchor().map(|CommentAnchor(line_no)| line_no)
114    }
115
116    fn ensure_rendered(&mut self, ctx: &ViewContext) {
117        self.ensure_cached_markdown(ctx);
118        self.ensure_cached_surface(ctx);
119    }
120
121    fn ensure_cached_markdown(&mut self, ctx: &ViewContext) {
122        self.cached_markdown.ensure(self.document.line_count(), || {
123            let result = render_markdown_source_lines(&self.document.markdown_text(), ctx);
124            PlanMarkdown { rendered_lines: result.lines }
125        });
126    }
127
128    fn ensure_cached_surface(&mut self, ctx: &ViewContext) {
129        let width = ctx.size.width;
130        let cached = self.cached_markdown.get().expect("markdown cache populated above");
131        let document = &self.document;
132        self.cached_surface.ensure(width, || {
133            let (surface, line_anchors) = build_plan_surface(document, &cached.rendered_lines, ctx);
134            PlanSurface { surface, line_anchors }
135        });
136    }
137}
138
139impl Component for PlanPanel {
140    type Message = PlanPanelMessage;
141
142    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
143        let cached = self.cached_surface.get()?;
144        let rows = &cached.surface;
145        let nav = Navigation::AnchorStep { anchors: &cached.line_anchors, page_size: PAGE_SIZE };
146
147        if let Event::Mouse(mouse) = event {
148            return match mouse.kind {
149                MouseEventKind::ScrollUp if !self.is_in_comment_mode() => {
150                    self.surface.on_mouse_scroll(-1, rows, nav);
151                    Some(vec![])
152                }
153                MouseEventKind::ScrollDown if !self.is_in_comment_mode() => {
154                    self.surface.on_mouse_scroll(1, rows, nav);
155                    Some(vec![])
156                }
157                _ => None,
158            };
159        }
160
161        let Event::Key(key) = event else {
162            return None;
163        };
164
165        match self.surface.on_key(key.code, rows, nav).await {
166            KeyOutcome::Event(ReviewSurfaceEvent::CommentSubmitted { anchor, text }) => {
167                self.queued_comments.push(ReviewComment::new(anchor, text));
168                Some(vec![])
169            }
170            KeyOutcome::Consumed => Some(vec![]),
171            KeyOutcome::PassThrough => None,
172        }
173    }
174
175    fn render(&mut self, ctx: &ViewContext) -> Frame {
176        let height = usize::from(ctx.size.height);
177
178        if self.document.lines.is_empty() {
179            return Frame::new(vec![Line::new("Plan is empty")]);
180        }
181
182        let cursor_anchor = self.current_cursor_anchor();
183        self.ensure_rendered(ctx);
184
185        let rendered_plan = &self.cached_surface.get().expect("rendered plan should exist").surface;
186        self.surface.restore_cursor(rendered_plan, cursor_anchor);
187
188        self.surface.render_body(rendered_plan, self.queued_comments.iter(), ctx, height)
189    }
190}
191
192fn build_plan_surface(
193    document: &PlanDocument,
194    rendered_lines: &[SourceMarkdownLine],
195    ctx: &ViewContext,
196) -> (AnchoredRows<usize>, AnchorIndex<usize>) {
197    let width = ctx.size.width;
198    let line_no_width = digit_count(document.line_count().max(1));
199    let mut rows = AnchoredSurfaceBuilder::new();
200    let mut line_anchors = AnchorIndex::default();
201
202    for rendered in rendered_lines {
203        let line_no = rendered.source_line_no;
204        let anchor = CommentAnchor(line_no);
205        line_anchors.push(anchor);
206
207        let (head, tail) = build_numbered_gutter(line_no, line_no_width, ctx);
208        rows.push_anchored_wrapped(anchor, rendered.line.clone(), width, &head, &tail);
209    }
210
211    (rows.finish(), line_anchors)
212}
213
214fn build_numbered_gutter(line_no: usize, line_no_width: usize, ctx: &ViewContext) -> (Line, Line) {
215    let theme = &ctx.theme;
216    let mut head = Line::default();
217    head.push_with_style(format!("{line_no:>line_no_width$}"), Style::fg(theme.text_secondary()));
218    head.push_with_style(" │ ", Style::fg(theme.muted()));
219    let tail = Line::new(" ".repeat(line_no_width + 3));
220    (head, tail)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use tui::{Event, KeyCode, KeyEvent, KeyModifiers};
227
228    fn make_document() -> PlanDocument {
229        PlanDocument::parse("/tmp/plan.md", "# Intro\n\nalpha\n\n## Details\n\nbeta")
230    }
231
232    #[tokio::test]
233    async fn movement_updates_cursor_anchor() {
234        let mut panel = PlanPanel::new(make_document());
235        let ctx = ViewContext::new((80, 24));
236        let _ = panel.render(&ctx);
237
238        panel.on_event(&Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE))).await.unwrap();
239
240        assert_eq!(panel.current_source_line_no(), 2);
241    }
242
243    #[tokio::test]
244    async fn comment_submission_adds_queued_comment() {
245        let mut panel = PlanPanel::new(make_document());
246        let ctx = ViewContext::new((80, 24));
247        let _ = panel.render(&ctx);
248
249        panel.on_event(&Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE))).await.unwrap();
250        panel.on_event(&Event::Key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))).await.unwrap();
251        panel.on_event(&Event::Key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))).await.unwrap();
252        panel.on_event(&Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))).await.unwrap();
253
254        assert_eq!(panel.comment_count(), 1);
255    }
256
257    #[test]
258    fn plan_surface_records_source_line_rows_and_insertion_rows() {
259        let document = PlanDocument::parse("/tmp/plan.md", &format!("# Intro\n\n{}\n\nshort", "x".repeat(120)));
260        let ctx = ViewContext::new((28, 20));
261        let result = render_markdown_source_lines(&document.markdown_text(), &ctx);
262        let (surface, line_anchors) = build_plan_surface(&document, &result.lines, &ctx);
263
264        assert!(line_anchors.as_slice().contains(&CommentAnchor(3)), "long source line should be anchored");
265        let start_row = surface.start_row_for_anchor(CommentAnchor(3)).expect("line should have a start row");
266        let end_row = surface.end_row_for_anchor(CommentAnchor(3)).expect("line should have an end row");
267
268        assert!(end_row > start_row, "wrapped source line should span multiple rows");
269    }
270}