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    state.document_area = document;
121    state.outline_area = outline;
122    if wide {
123        buffer.set_style(
124            separator,
125            Style::new().fg(theme.ui.border).bg(theme.ui.canvas),
126        );
127        render_outline(outline, buffer, state, theme);
128    }
129    let (rows, track) = rows_and_track(document, true);
130    state.last_height = usize::from(rows.height).max(1);
131    let gutter_width = source_gutter_width(state);
132    let content_width = rows.width.saturating_sub(gutter_width).max(1);
133    let layout = state.ensure_layout(content_width);
134    state.follow_selection(&layout);
135    let last = layout.len().saturating_sub(state.last_height);
136    state.scroll = state.scroll.min(last);
137    if layout.is_empty() {
138        EmptyState::new("No Markdown content to review", NoticeTone::Neutral, theme)
139            .render(rows, buffer);
140    } else {
141        render_rows(rows, track, buffer, state, theme, &layout, gutter_width);
142    }
143}
144
145fn source_gutter_width(state: &MarkdownReviewState) -> u16 {
146    let line_count = state.document().source().split('\n').count().max(1);
147    let digits = line_count.checked_ilog10().unwrap_or(0).saturating_add(1);
148    u16::try_from(digits)
149        .unwrap_or(u16::MAX)
150        .saturating_add(GUTTER_SEPARATOR_WIDTH)
151}
152
153fn render_outline(
154    area: Rect,
155    buffer: &mut Buffer,
156    state: &mut MarkdownReviewState,
157    theme: &RatatuiTheme,
158) {
159    if area.is_empty() {
160        return;
161    }
162    let heading_count = state.document().outline().len();
163    state.outline_selected = state.outline_selected.min(heading_count.saturating_sub(1));
164    let height = usize::from(area.height);
165    if state.follow_pending {
166        if state.outline_selected < state.outline_scroll {
167            state.outline_scroll = state.outline_selected;
168        } else if state.outline_selected >= state.outline_scroll.saturating_add(height) {
169            state.outline_scroll = state
170                .outline_selected
171                .saturating_sub(height.saturating_sub(1));
172        }
173    }
174    let max_scroll = heading_count.saturating_sub(height.max(1));
175    state.outline_scroll = state.outline_scroll.min(max_scroll);
176    let headings = state.document().outline().to_vec();
177    for (offset, heading) in headings
178        .iter()
179        .skip(state.outline_scroll)
180        .take(height)
181        .enumerate()
182    {
183        let row = Rect::new(
184            area.x,
185            area.y + u16::try_from(offset).unwrap_or(u16::MAX),
186            area.width,
187            1,
188        );
189        let index = state.outline_scroll + offset;
190        let selected = state.focus == MarkdownFocusPane::Outline && index == state.outline_selected;
191        let indent = "  ".repeat(usize::from(heading.level.saturating_sub(1)));
192        let selection = if selected {
193            SelectionState::Focused
194        } else {
195            SelectionState::None
196        };
197        Paragraph::new(Line::from(format!("{indent}{}", heading.title)))
198            .style(theme.ui.selection_style(selection))
199            .render(row, buffer);
200        state.hit_regions.push(MarkdownHitRegion {
201            area: row,
202            target: Some(heading.target_id),
203            outline: true,
204        });
205    }
206}
207
208fn render_rows(
209    area: Rect,
210    track: Rect,
211    buffer: &mut Buffer,
212    state: &mut MarkdownReviewState,
213    theme: &RatatuiTheme,
214    layout: &MarkdownVisualLayout,
215    gutter_width: u16,
216) {
217    let selected = state.selected_target();
218    let focused = state.focus == MarkdownFocusPane::Document;
219    for (drawn, index) in (state.scroll..layout.len()).enumerate() {
220        let y = area
221            .y
222            .saturating_add(u16::try_from(drawn).unwrap_or(u16::MAX));
223        if y >= area.bottom() {
224            break;
225        }
226        let row_area = Rect::new(area.x, y, area.width, 1);
227        let row_gutter_width = gutter_width.min(row_area.width.saturating_sub(1));
228        let [gutter_area, content_area] =
229            Layout::horizontal([Constraint::Length(row_gutter_width), Constraint::Min(1)])
230                .areas(row_area);
231        let Some(row) = layout.row(index) else {
232            break;
233        };
234        if let MarkdownVisualRow::Annotation {
235            annotation,
236            line,
237            target,
238        } = row
239        {
240            render_annotation_line(content_area, buffer, theme, annotation, line);
241            if let Some(column) = annotation.cursor_column(line) {
242                state.set_cursor(Some(Position::new(
243                    content_area
244                        .x
245                        .saturating_add(column)
246                        .min(content_area.right().saturating_sub(1)),
247                    y,
248                )));
249            }
250            if target.is_some() {
251                state.hit_regions.push(MarkdownHitRegion {
252                    area: row_area,
253                    target,
254                    outline: false,
255                });
256            }
257            continue;
258        }
259        let is_selected = focused && row.target().is_some_and(|target| Some(target) == selected);
260        let background = if is_selected {
261            theme.ui.surface_selected
262        } else {
263            theme.ui.canvas
264        };
265        buffer.set_style(row_area, Style::new().fg(theme.ui.text).bg(background));
266        let gutter = row.source_line().map_or_else(
267            || " ".repeat(usize::from(row_gutter_width)),
268            |line| {
269                let number_width =
270                    usize::from(row_gutter_width.saturating_sub(GUTTER_SEPARATOR_WIDTH));
271                format!("{line:>number_width$} │ ")
272            },
273        );
274        Paragraph::new(gutter)
275            .style(Style::new().fg(theme.gutter).bg(background))
276            .render(gutter_area, buffer);
277        if let MarkdownVisualRow::Content(content) = row {
278            let mut line = content.line.clone();
279            if is_selected {
280                line.style = line.style.bg(background);
281                for span in &mut line.spans {
282                    span.style = span.style.bg(background);
283                }
284            }
285            Paragraph::new(line).render(content_area, buffer);
286        }
287        if let Some(target) = row.target() {
288            state.hit_regions.push(MarkdownHitRegion {
289                area: row_area,
290                target: Some(target),
291                outline: false,
292            });
293        }
294    }
295    render_vertical_scrollbar(
296        track,
297        buffer,
298        layout.len(),
299        usize::from(area.height),
300        state.scroll,
301    );
302}
303
304fn render_footer(
305    area: Rect,
306    buffer: &mut Buffer,
307    state: &MarkdownReviewState,
308    theme: &RatatuiTheme,
309) {
310    if area.is_empty() {
311        return;
312    }
313    let count = state.review().len();
314    let status = format!("  {count} comment{}", if count == 1 { "" } else { "s" });
315    let mut actions = if state.session.draft().is_some() {
316        vec![
317            ActionLabel::new("Enter", "save", theme)
318                .variant(ButtonVariant::Primary)
319                .into_span(),
320            ActionLabel::new("Shift-Enter", "newline", theme).into_span(),
321            ActionLabel::new("Esc", "cancel", theme).into_span(),
322        ]
323    } else {
324        vec![Span::styled(
325            state.footer_hint(usize::from(area.width).saturating_sub(status.len())),
326            Style::new().fg(theme.ui.text_muted),
327        )]
328    };
329    actions.push(Span::styled(status, Style::new().fg(theme.ui.accent)));
330    ActionBar::new(Line::from(actions), theme).render(area, buffer);
331}
332
333fn render_help(area: Rect, buffer: &mut Buffer, state: &MarkdownReviewState, theme: &RatatuiTheme) {
334    let content = Modal::new("Markdown shortcuts", ModalSize::Wide, theme)
335        .hint("j/k scroll · ? / Esc close")
336        .render(area, buffer);
337    render_modal_text(
338        content,
339        buffer,
340        state
341            .help_bindings()
342            .skip(state.help_scroll)
343            .map(KeyBinding::hint)
344            .collect::<Vec<_>>()
345            .join("\n"),
346        theme,
347    );
348}