Skip to main content

clankerdiff_ratatui/markdown_review/
render.rs

1use super::{
2    layout::{MarkdownVisualLayout, MarkdownVisualRow},
3    state::{MarkdownFocusPane, MarkdownHitRegion, MarkdownReviewState},
4};
5use crate::{
6    KeyBinding, RatatuiTheme,
7    annotation::render_annotation_line,
8    theme_picker::render_theme_picker,
9    ui::{
10        ActionBar, ActionLabel, AppFrame, ButtonVariant, EmptyState, Modal, ModalSize, NoticeTone,
11        SelectionState, render_modal_text,
12    },
13    widgets::{render_vertical_scrollbar, rows_and_track},
14};
15use ratatui::{
16    buffer::Buffer,
17    layout::{Constraint, Layout, Position, Rect},
18    style::Style,
19    text::{Line, Span},
20    widgets::{Paragraph, StatefulWidget, Widget},
21};
22
23const GUTTER_SEPARATOR_WIDTH: u16 = 3;
24const OUTLINE_WIDTH: u16 = 28;
25const OUTLINE_BREAKPOINT: u16 = 90;
26
27/// Stateful Ratatui Markdown review widget.
28#[derive(Debug, Clone)]
29pub struct MarkdownReviewWidget {
30    title: String,
31    borders: bool,
32}
33
34impl Default for MarkdownReviewWidget {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl MarkdownReviewWidget {
41    /// Creates a bordered widget titled “Markdown Review”.
42    #[must_use]
43    pub fn new() -> Self {
44        Self {
45            title: "Markdown Review".to_owned(),
46            borders: true,
47        }
48    }
49
50    /// Sets the outer title.
51    #[must_use]
52    pub fn title(mut self, title: impl Into<String>) -> Self {
53        self.title = title.into();
54        self
55    }
56
57    /// Enables or disables the outer border.
58    #[must_use]
59    pub const fn borders(mut self, borders: bool) -> Self {
60        self.borders = borders;
61        self
62    }
63}
64
65impl StatefulWidget for MarkdownReviewWidget {
66    type State = MarkdownReviewState;
67
68    fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
69        state.set_cursor(None);
70        state.clear_hit_regions();
71        let theme = RatatuiTheme::from(&state.theme);
72        let regions = AppFrame::new(&self.title, self.borders, &theme)
73            .footer(state.options.footer)
74            .render(area, buffer);
75        if regions.body.is_empty() {
76            state.dirty = false;
77            return;
78        }
79        let body = regions.body;
80        let footer = regions.footer;
81        render_body(body, buffer, state, &theme);
82        render_footer(footer, buffer, state, &theme);
83        if state.help {
84            render_help(area, buffer, state, &theme);
85        }
86        if let Some(picker) = &state.theme_picker {
87            render_theme_picker(area, buffer, picker, &theme);
88        }
89        state.dirty = false;
90    }
91}
92
93fn render_body(
94    area: Rect,
95    buffer: &mut Buffer,
96    state: &mut MarkdownReviewState,
97    theme: &RatatuiTheme,
98) {
99    state.clear_hit_regions();
100    if area.is_empty() {
101        return;
102    }
103    let outline_width = state.options.navigation.width(
104        area.width,
105        OUTLINE_BREAKPOINT,
106        OUTLINE_WIDTH.min(area.width / 3),
107    );
108    let wide = outline_width > 0 && !state.document().outline().is_empty();
109    let (outline, separator, document) = if wide {
110        let [outline, separator, document] = Layout::horizontal([
111            Constraint::Length(outline_width),
112            Constraint::Length(1),
113            Constraint::Min(1),
114        ])
115        .areas(area);
116        (outline, separator, document)
117    } else {
118        (Rect::default(), Rect::default(), area)
119    };
120    if wide {
121        buffer.set_style(
122            separator,
123            Style::new().fg(theme.ui.border).bg(theme.ui.canvas),
124        );
125        render_outline(outline, buffer, state, theme);
126    }
127    let (rows, track) = rows_and_track(document, true);
128    state.last_height = usize::from(rows.height).max(1);
129    let gutter_width = source_gutter_width(state);
130    let content_width = rows.width.saturating_sub(gutter_width).max(1);
131    let layout = state.ensure_layout(content_width);
132    state.follow_selection(&layout);
133    let last = layout.len().saturating_sub(state.last_height);
134    state.scroll = state.scroll.min(last);
135    if layout.is_empty() {
136        EmptyState::new("No Markdown content to review", NoticeTone::Neutral, theme)
137            .render(rows, buffer);
138    } else {
139        render_rows(rows, track, buffer, state, theme, &layout, gutter_width);
140    }
141}
142
143fn source_gutter_width(state: &MarkdownReviewState) -> u16 {
144    let line_count = state.document().source().split('\n').count().max(1);
145    let digits = line_count.checked_ilog10().unwrap_or(0).saturating_add(1);
146    u16::try_from(digits)
147        .unwrap_or(u16::MAX)
148        .saturating_add(GUTTER_SEPARATOR_WIDTH)
149}
150
151fn render_outline(
152    area: Rect,
153    buffer: &mut Buffer,
154    state: &mut MarkdownReviewState,
155    theme: &RatatuiTheme,
156) {
157    if area.is_empty() {
158        return;
159    }
160    let heading_count = state.document().outline().len();
161    state.outline_selected = state.outline_selected.min(heading_count.saturating_sub(1));
162    let height = usize::from(area.height);
163    if state.follow_pending {
164        if state.outline_selected < state.outline_scroll {
165            state.outline_scroll = state.outline_selected;
166        } else if state.outline_selected >= state.outline_scroll.saturating_add(height) {
167            state.outline_scroll = state
168                .outline_selected
169                .saturating_sub(height.saturating_sub(1));
170        }
171    }
172    let max_scroll = heading_count.saturating_sub(height.max(1));
173    state.outline_scroll = state.outline_scroll.min(max_scroll);
174    let headings = state.document().outline().to_vec();
175    for (offset, heading) in headings
176        .iter()
177        .skip(state.outline_scroll)
178        .take(height)
179        .enumerate()
180    {
181        let row = Rect::new(
182            area.x,
183            area.y + u16::try_from(offset).unwrap_or(u16::MAX),
184            area.width,
185            1,
186        );
187        let index = state.outline_scroll + offset;
188        let selected = state.focus == MarkdownFocusPane::Outline && index == state.outline_selected;
189        let indent = "  ".repeat(usize::from(heading.level.saturating_sub(1)));
190        let selection = if selected {
191            SelectionState::Focused
192        } else {
193            SelectionState::None
194        };
195        Paragraph::new(Line::from(format!("{indent}{}", heading.title)))
196            .style(theme.ui.selection_style(selection))
197            .render(row, buffer);
198        state.hit_regions.push(MarkdownHitRegion {
199            area: row,
200            target: Some(heading.target_id),
201            outline: true,
202        });
203    }
204}
205
206fn render_rows(
207    area: Rect,
208    track: Rect,
209    buffer: &mut Buffer,
210    state: &mut MarkdownReviewState,
211    theme: &RatatuiTheme,
212    layout: &MarkdownVisualLayout,
213    gutter_width: u16,
214) {
215    let selected = state.selected_target();
216    let focused = state.focus == MarkdownFocusPane::Document;
217    for (drawn, index) in (state.scroll..layout.len()).enumerate() {
218        let y = area
219            .y
220            .saturating_add(u16::try_from(drawn).unwrap_or(u16::MAX));
221        if y >= area.bottom() {
222            break;
223        }
224        let row_area = Rect::new(area.x, y, area.width, 1);
225        let row_gutter_width = gutter_width.min(row_area.width.saturating_sub(1));
226        let [gutter_area, content_area] =
227            Layout::horizontal([Constraint::Length(row_gutter_width), Constraint::Min(1)])
228                .areas(row_area);
229        let Some(row) = layout.row(index) else {
230            break;
231        };
232        if let MarkdownVisualRow::Annotation {
233            annotation,
234            line,
235            target,
236        } = row
237        {
238            render_annotation_line(content_area, buffer, theme, annotation, line);
239            if let Some(column) = annotation.cursor_column(line) {
240                state.set_cursor(Some(Position::new(
241                    content_area
242                        .x
243                        .saturating_add(column)
244                        .min(content_area.right().saturating_sub(1)),
245                    y,
246                )));
247            }
248            if target.is_some() {
249                state.hit_regions.push(MarkdownHitRegion {
250                    area: row_area,
251                    target,
252                    outline: false,
253                });
254            }
255            continue;
256        }
257        let is_selected = focused && row.target().is_some_and(|target| Some(target) == selected);
258        let background = if is_selected {
259            theme.ui.surface_selected
260        } else {
261            theme.ui.canvas
262        };
263        buffer.set_style(row_area, Style::new().fg(theme.ui.text).bg(background));
264        let gutter = row.source_line().map_or_else(
265            || " ".repeat(usize::from(row_gutter_width)),
266            |line| {
267                let number_width =
268                    usize::from(row_gutter_width.saturating_sub(GUTTER_SEPARATOR_WIDTH));
269                format!("{line:>number_width$} │ ")
270            },
271        );
272        Paragraph::new(gutter)
273            .style(Style::new().fg(theme.gutter).bg(background))
274            .render(gutter_area, buffer);
275        if let MarkdownVisualRow::Content(content) = row {
276            let mut line = content.line.clone();
277            if is_selected {
278                line.style = line.style.bg(background);
279                for span in &mut line.spans {
280                    span.style = span.style.bg(background);
281                }
282            }
283            Paragraph::new(line).render(content_area, buffer);
284        }
285        if let Some(target) = row.target() {
286            state.hit_regions.push(MarkdownHitRegion {
287                area: row_area,
288                target: Some(target),
289                outline: false,
290            });
291        }
292    }
293    render_vertical_scrollbar(
294        track,
295        buffer,
296        layout.len(),
297        usize::from(area.height),
298        state.scroll,
299    );
300}
301
302fn render_footer(
303    area: Rect,
304    buffer: &mut Buffer,
305    state: &MarkdownReviewState,
306    theme: &RatatuiTheme,
307) {
308    if area.is_empty() {
309        return;
310    }
311    let count = state.review().len();
312    let status = format!("  {count} comment{}", if count == 1 { "" } else { "s" });
313    let mut actions = if state.session.draft().is_some() {
314        vec![
315            ActionLabel::new("Enter", "save", theme)
316                .variant(ButtonVariant::Primary)
317                .into_span(),
318            ActionLabel::new("Shift-Enter", "newline", theme).into_span(),
319            ActionLabel::new("Esc", "cancel", theme).into_span(),
320        ]
321    } else {
322        vec![Span::styled(
323            state.footer_hint(usize::from(area.width).saturating_sub(status.len())),
324            Style::new().fg(theme.ui.text_muted),
325        )]
326    };
327    actions.push(Span::styled(status, Style::new().fg(theme.ui.accent)));
328    ActionBar::new(Line::from(actions), theme).render(area, buffer);
329}
330
331fn render_help(area: Rect, buffer: &mut Buffer, state: &MarkdownReviewState, theme: &RatatuiTheme) {
332    let content = Modal::new("Markdown shortcuts", ModalSize::Wide, theme)
333        .hint("j/k scroll · ? / Esc close")
334        .render(area, buffer);
335    render_modal_text(
336        content,
337        buffer,
338        state
339            .help_bindings()
340            .skip(state.help_scroll)
341            .map(KeyBinding::hint)
342            .collect::<Vec<_>>()
343            .join("\n"),
344        theme,
345    );
346}