Skip to main content

clankerdiff_ratatui/markdown_review/
render.rs

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/// Stateful Ratatui Markdown review widget.
30#[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    /// Creates a bordered widget titled “Markdown Review”.
44    #[must_use]
45    pub fn new() -> Self {
46        Self {
47            title: "Markdown Review".to_owned(),
48            borders: true,
49        }
50    }
51
52    /// Sets the outer title.
53    #[must_use]
54    pub fn title(mut self, title: impl Into<String>) -> Self {
55        self.title = title.into();
56        self
57    }
58
59    /// Enables or disables the outer border.
60    #[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}