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