1use super::{
2 layout::{MarkdownSpan, MarkdownTextStyle, MarkdownVisualLayout},
3 state::{MarkdownFocusPane, MarkdownReviewState},
4};
5use crate::{
6 RatatuiTheme,
7 annotation::render_annotation_line,
8 style::syntax_style,
9 theme_picker::render_theme_picker,
10 ui::{
11 ActionBar, ActionLabel, AppFrame, ButtonVariant, EmptyState, Modal, ModalSize, NoticeTone,
12 SelectableRow, SelectionState, render_modal_text,
13 },
14 widgets::{render_vertical_scrollbar, rows_and_track},
15};
16use clankerdiff_syntax::HighlightSpan;
17use ratatui::{
18 buffer::Buffer,
19 layout::{Constraint, Layout, Rect},
20 style::{Modifier, Style},
21 text::{Line, Span},
22 widgets::{Paragraph, StatefulWidget, Widget},
23};
24
25const GUTTER_SEPARATOR_WIDTH: u16 = 3;
26const OUTLINE_WIDTH: u16 = 28;
27const OUTLINE_BREAKPOINT: u16 = 90;
28
29#[derive(Debug, Clone)]
31pub struct MarkdownReviewWidget {
32 title: String,
33 borders: bool,
34}
35
36impl Default for MarkdownReviewWidget {
37 fn default() -> Self {
38 Self::new()
39 }
40}
41
42impl MarkdownReviewWidget {
43 #[must_use]
45 pub fn new() -> Self {
46 Self {
47 title: "Markdown Review".to_owned(),
48 borders: true,
49 }
50 }
51
52 #[must_use]
54 pub fn title(mut self, title: impl Into<String>) -> Self {
55 self.title = title.into();
56 self
57 }
58
59 #[must_use]
61 pub const fn borders(mut self, borders: bool) -> Self {
62 self.borders = borders;
63 self
64 }
65}
66
67impl StatefulWidget for MarkdownReviewWidget {
68 type State = MarkdownReviewState;
69
70 fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
71 state.set_cursor(None);
72 let theme = RatatuiTheme::from(&state.theme);
73 let regions = AppFrame::new(&self.title, self.borders, &theme).render(area, buffer);
74 if regions.body.is_empty() {
75 state.dirty = false;
76 return;
77 }
78 let body = regions.body;
79 let footer = regions.footer;
80 render_body(body, buffer, state, &theme);
81 render_footer(footer, buffer, state, &theme);
82 if state.help {
83 render_help(area, buffer, &theme);
84 }
85 if let Some(picker) = &state.theme_picker {
86 render_theme_picker(area, buffer, picker, &theme);
87 }
88 state.dirty = false;
89 }
90}
91
92fn render_body(
93 area: Rect,
94 buffer: &mut Buffer,
95 state: &mut MarkdownReviewState,
96 theme: &RatatuiTheme,
97) {
98 state.clear_hit_regions();
99 if area.is_empty() {
100 return;
101 }
102 let wide = area.width >= OUTLINE_BREAKPOINT && !state.document().outline().is_empty();
103 let (outline, separator, document) = if wide {
104 let [outline, separator, document] = Layout::horizontal([
105 Constraint::Length(OUTLINE_WIDTH.min(area.width / 3)),
106 Constraint::Length(1),
107 Constraint::Min(1),
108 ])
109 .areas(area);
110 (outline, separator, document)
111 } else {
112 (Rect::default(), Rect::default(), area)
113 };
114 if wide {
115 buffer.set_style(
116 separator,
117 Style::new().fg(theme.ui.border).bg(theme.ui.canvas),
118 );
119 render_outline(outline, buffer, state, theme);
120 }
121 let (rows, track) = rows_and_track(document, true);
122 state.last_height = usize::from(rows.height).max(1);
123 let gutter_width = source_gutter_width(state);
124 let content_width = rows.width.saturating_sub(gutter_width).max(1);
125 let layout = state.ensure_layout(content_width);
126 state.follow_selection(&layout);
127 let last = layout.rows.len().saturating_sub(state.last_height);
128 state.scroll = state.scroll.min(last);
129 if layout.rows.is_empty() {
130 EmptyState::new("No Markdown content to review", NoticeTone::Info, theme)
131 .render(rows, buffer);
132 } else {
133 render_rows(rows, track, buffer, state, theme, &layout, gutter_width);
134 }
135}
136
137fn source_gutter_width(state: &MarkdownReviewState) -> u16 {
138 let line_count = state.document().source().split('\n').count().max(1);
139 let digits = line_count.checked_ilog10().unwrap_or(0).saturating_add(1);
140 u16::try_from(digits)
141 .unwrap_or(u16::MAX)
142 .saturating_add(GUTTER_SEPARATOR_WIDTH)
143}
144
145fn render_outline(
146 area: Rect,
147 buffer: &mut Buffer,
148 state: &mut MarkdownReviewState,
149 theme: &RatatuiTheme,
150) {
151 if area.is_empty() {
152 return;
153 }
154 let heading_count = state.document().outline().len();
155 state.outline_selected = state.outline_selected.min(heading_count.saturating_sub(1));
156 let height = usize::from(area.height);
157 let max_scroll = heading_count.saturating_sub(height.max(1));
158 state.outline_scroll = state.outline_scroll.min(max_scroll);
159 let headings = state.document().outline().to_vec();
160 for (offset, heading) in headings
161 .iter()
162 .skip(state.outline_scroll)
163 .take(height)
164 .enumerate()
165 {
166 let row = Rect::new(
167 area.x,
168 area.y + u16::try_from(offset).unwrap_or(u16::MAX),
169 area.width,
170 1,
171 );
172 let index = state.outline_scroll + offset;
173 let selected = state.focus == MarkdownFocusPane::Outline && index == state.outline_selected;
174 let indent = " ".repeat(usize::from(heading.level.saturating_sub(1)));
175 SelectableRow::new(
176 Line::from(format!("{indent}{}", heading.title)),
177 if selected {
178 SelectionState::Focused
179 } else {
180 SelectionState::None
181 },
182 theme,
183 )
184 .render(row, buffer);
185 state.hit_regions.push(super::state::MarkdownHitRegion {
186 area: row,
187 target: Some(heading.target_id),
188 outline: true,
189 });
190 }
191}
192
193fn render_rows(
194 area: Rect,
195 track: Rect,
196 buffer: &mut Buffer,
197 state: &mut MarkdownReviewState,
198 theme: &RatatuiTheme,
199 layout: &MarkdownVisualLayout,
200 gutter_width: u16,
201) {
202 let selected = state.selected_target();
203 let focused = state.focus == MarkdownFocusPane::Document;
204 for (drawn, index) in (state.scroll..layout.rows.len()).enumerate() {
205 let y = area
206 .y
207 .saturating_add(u16::try_from(drawn).unwrap_or(u16::MAX));
208 if y >= area.bottom() {
209 break;
210 }
211 let row_area = Rect::new(area.x, y, area.width, 1);
212 let row_gutter_width = gutter_width.min(row_area.width.saturating_sub(1));
213 let [gutter_area, content_area] =
214 Layout::horizontal([Constraint::Length(row_gutter_width), Constraint::Min(1)])
215 .areas(row_area);
216 let row = &layout.rows[index];
217 if let Some((annotation, line)) = &row.annotation {
218 render_annotation_line(content_area, buffer, theme, annotation, *line);
219 if let Some(column) = annotation.cursor_column(*line) {
220 state.set_cursor(Some(ratatui::layout::Position::new(
221 content_area
222 .x
223 .saturating_add(column)
224 .min(content_area.right().saturating_sub(1)),
225 y,
226 )));
227 }
228 continue;
229 }
230 let is_selected = focused && row.target.is_some() && row.target == selected;
231 let background = if is_selected {
232 theme.ui.surface_selected
233 } else {
234 theme.ui.canvas
235 };
236 buffer.set_style(row_area, Style::new().fg(theme.ui.text).bg(background));
237 let gutter = row.source_line.map_or_else(
238 || " ".repeat(usize::from(row_gutter_width)),
239 |line| {
240 let number_width =
241 usize::from(row_gutter_width.saturating_sub(GUTTER_SEPARATOR_WIDTH));
242 format!("{line:>number_width$} │ ")
243 },
244 );
245 Paragraph::new(gutter)
246 .style(Style::new().fg(theme.gutter).bg(background))
247 .render(gutter_area, buffer);
248 let mut spans = Vec::new();
249 spans.push(Span::styled(
250 row.prefix.clone(),
251 Style::new()
252 .fg(if row.code.is_some() {
253 theme.gutter
254 } else {
255 theme.ui.text_muted
256 })
257 .bg(background),
258 ));
259 if let Some(code) = &row.code {
260 let source = row
261 .spans
262 .iter()
263 .map(|span| span.text.as_str())
264 .collect::<String>();
265 spans.extend(highlighted_spans(&source, &code.highlights, background));
266 } else {
267 spans.extend(
268 row.spans
269 .iter()
270 .map(|span| styled_span(span, theme, background)),
271 );
272 }
273 Paragraph::new(Line::from(spans)).render(content_area, buffer);
274 if row.selectable {
275 state.hit_regions.push(super::state::MarkdownHitRegion {
276 area: row_area,
277 target: row.target,
278 outline: false,
279 });
280 }
281 }
282 render_vertical_scrollbar(
283 track,
284 buffer,
285 layout.rows.len(),
286 usize::from(area.height),
287 state.scroll,
288 );
289}
290
291fn styled_span(
292 span: &MarkdownSpan,
293 theme: &RatatuiTheme,
294 background: ratatui::style::Color,
295) -> Span<'static> {
296 let mut style = Style::new()
297 .fg(match span.style {
298 MarkdownTextStyle::Heading | MarkdownTextStyle::Link | MarkdownTextStyle::Image => {
299 theme.ui.accent
300 }
301 MarkdownTextStyle::Muted => theme.ui.text_muted,
302 _ => theme.ui.text,
303 })
304 .bg(background);
305 match span.style {
306 MarkdownTextStyle::Heading | MarkdownTextStyle::Strong => {
307 style = style.add_modifier(Modifier::BOLD);
308 }
309 MarkdownTextStyle::Emphasis => style = style.add_modifier(Modifier::ITALIC),
310 MarkdownTextStyle::Strikethrough => style = style.add_modifier(Modifier::CROSSED_OUT),
311 MarkdownTextStyle::Link => style = style.add_modifier(Modifier::UNDERLINED),
312 MarkdownTextStyle::InlineCode => style = style.bg(theme.ui.border),
313 _ => {}
314 }
315 Span::styled(span.text.clone(), style)
316}
317
318fn highlighted_spans(
319 source: &str,
320 highlights: &[HighlightSpan],
321 background: ratatui::style::Color,
322) -> Vec<Span<'static>> {
323 if highlights.is_empty() {
324 return vec![Span::styled(source.to_owned(), Style::new().bg(background))];
325 }
326 let mut output = Vec::new();
327 let mut offset = 0;
328 for highlight in highlights {
329 let start = highlight.range.start.min(source.len());
330 let end = highlight.range.end.min(source.len());
331 if start > offset && source.is_char_boundary(start) {
332 output.push(Span::styled(
333 source[offset..start].to_owned(),
334 Style::new().bg(background),
335 ));
336 }
337 if end > start && source.is_char_boundary(start) && source.is_char_boundary(end) {
338 output.push(Span::styled(
339 source[start..end].to_owned(),
340 syntax_style(highlight.foreground, highlight.font_style, background),
341 ));
342 offset = end;
343 }
344 }
345 if offset < source.len() && source.is_char_boundary(offset) {
346 output.push(Span::styled(
347 source[offset..].to_owned(),
348 Style::new().bg(background),
349 ));
350 }
351 output
352}
353
354fn render_footer(
355 area: Rect,
356 buffer: &mut Buffer,
357 state: &MarkdownReviewState,
358 theme: &RatatuiTheme,
359) {
360 if area.is_empty() {
361 return;
362 }
363 let mut actions = if state.session.draft().is_some() {
364 vec![
365 ActionLabel::new("Enter", "save", theme)
366 .variant(ButtonVariant::Primary)
367 .into_span(),
368 ActionLabel::new("Shift-Enter", "newline", theme).into_span(),
369 ActionLabel::new("Esc", "cancel", theme).into_span(),
370 ]
371 } else {
372 vec![
373 Span::styled(
374 "[j/k] target [n/p] heading ",
375 Style::new().fg(theme.ui.text_muted),
376 ),
377 ActionLabel::new("c", "comment", theme).into_span(),
378 ActionLabel::new("a", "approve", theme)
379 .variant(ButtonVariant::Primary)
380 .into_span(),
381 ActionLabel::new("r", "request changes", theme)
382 .variant(ButtonVariant::Destructive)
383 .into_span(),
384 Span::styled("[t] theme [?] help", Style::new().fg(theme.ui.text_muted)),
385 ]
386 };
387 let count = state.review().len();
388 actions.push(Span::styled(
389 format!(" {count} comment{}", if count == 1 { "" } else { "s" }),
390 Style::new().fg(theme.ui.accent),
391 ));
392 ActionBar::new(Line::from(actions), theme).render(area, buffer);
393}
394
395fn render_help(area: Rect, buffer: &mut Buffer, theme: &RatatuiTheme) {
396 let content = Modal::new("Markdown shortcuts", theme)
397 .hint("? / Esc to close")
398 .size(ModalSize::Wide)
399 .render(area, buffer);
400 render_modal_text(
401 content,
402 buffer,
403 "Navigation\n j/k or arrows move target\n g/G or Home/End first/last\n n/p next/previous heading\n h/l or Enter outline/document\n\nReview\n c/e/x/u add/edit/delete/undo\n a/r approve/request changes\n t select theme\n Esc cancel draft/review\n ? close help",
404 theme,
405 );
406}