wisp/components/plan_review/
plan_panel.rs1use 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}