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#[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 #[must_use]
43 pub fn new() -> Self {
44 Self {
45 title: "Markdown Review".to_owned(),
46 borders: true,
47 }
48 }
49
50 #[must_use]
52 pub fn title(mut self, title: impl Into<String>) -> Self {
53 self.title = title.into();
54 self
55 }
56
57 #[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}