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