Skip to main content

clankerdiff_ratatui/markdown_review/
state.rs

1use super::layout::MarkdownVisualLayout;
2use crate::{
3    KeyBinding, MarkdownReviewCommand, NavigationPane, ReviewOptions, ThemeChoice,
4    default_markdown_keybindings, theme_picker::ThemePicker,
5};
6use clankerdiff_core::ReviewCapabilities;
7use clankerdiff_markdown::{
8    MarkdownDocument, MarkdownReview, MarkdownReviewSession, MarkdownTargetId,
9};
10use clankerdiff_syntax::{HighlightStats, SyntaxHighlighter};
11use clankerdiff_theme::ReviewTheme;
12use ratatui::layout::{Position, Rect};
13use std::{
14    hash::{Hash, Hasher},
15    sync::Arc,
16};
17
18pub use clankerdiff_markdown::MarkdownFocusPane;
19
20#[derive(Debug, Clone, Copy)]
21pub(crate) struct MarkdownHitRegion {
22    pub area: Rect,
23    pub target: Option<MarkdownTargetId>,
24    pub outline: bool,
25}
26
27#[derive(Debug)]
28struct CachedLayout {
29    key: u64,
30    width: u16,
31    layout: Arc<MarkdownVisualLayout>,
32}
33
34/// Persistent state for [`crate::MarkdownReviewWidget`].
35#[derive(Debug)]
36pub struct MarkdownReviewState {
37    pub(crate) session: MarkdownReviewSession,
38    pub(crate) options: ReviewOptions,
39    pub(crate) capabilities: ReviewCapabilities,
40    pub(crate) keybindings: Vec<KeyBinding<MarkdownReviewCommand>>,
41    pub(crate) theme_choices: Arc<[ThemeChoice]>,
42    pub(crate) theme: ReviewTheme,
43    pub(crate) highlighter: SyntaxHighlighter,
44    pub(crate) focus: MarkdownFocusPane,
45    pub(crate) scroll: usize,
46    pub(crate) outline_scroll: usize,
47    pub(crate) outline_selected: usize,
48    pub(crate) last_height: usize,
49    layout: Option<CachedLayout>,
50    pub(crate) hit_regions: Vec<MarkdownHitRegion>,
51    pub(crate) document_area: Rect,
52    pub(crate) outline_area: Rect,
53    pub(crate) cursor_position: Option<Position>,
54    pub(crate) help: bool,
55    pub(crate) help_scroll: usize,
56    pub(crate) theme_picker: Option<ThemePicker>,
57    pub(crate) dirty: bool,
58    pub(crate) follow_pending: bool,
59}
60
61impl MarkdownReviewState {
62    /// Creates ready state from an immutable parsed document.
63    #[must_use]
64    pub fn new(document: Arc<MarkdownDocument>) -> Self {
65        Self::with_theme(document, ReviewTheme::default())
66    }
67
68    /// Creates state with an explicit shared neutral theme.
69    #[must_use]
70    pub fn with_theme(document: Arc<MarkdownDocument>, theme: ReviewTheme) -> Self {
71        Self {
72            session: MarkdownReviewSession::new(document),
73            options: ReviewOptions::default(),
74            capabilities: ReviewCapabilities::default(),
75            keybindings: default_markdown_keybindings(),
76            theme_choices: Arc::from([]),
77            theme,
78            highlighter: SyntaxHighlighter::default(),
79            focus: MarkdownFocusPane::Document,
80            scroll: 0,
81            outline_scroll: 0,
82            outline_selected: 0,
83            last_height: 1,
84            layout: None,
85            hit_regions: Vec::new(),
86            document_area: Rect::default(),
87            outline_area: Rect::default(),
88            cursor_position: None,
89            help: false,
90            help_scroll: 0,
91            theme_picker: None,
92            dirty: true,
93            follow_pending: true,
94        }
95    }
96
97    #[must_use]
98    pub const fn session(&self) -> &MarkdownReviewSession {
99        &self.session
100    }
101
102    pub const fn session_mut(&mut self) -> &mut MarkdownReviewSession {
103        &mut self.session
104    }
105
106    #[must_use]
107    pub const fn document(&self) -> &Arc<MarkdownDocument> {
108        self.session.document()
109    }
110
111    #[must_use]
112    pub const fn review(&self) -> &MarkdownReview {
113        self.session.review()
114    }
115
116    pub const fn review_mut(&mut self) -> &mut MarkdownReview {
117        self.session.review_mut()
118    }
119
120    #[must_use]
121    pub const fn selected_target(&self) -> Option<MarkdownTargetId> {
122        self.session.selected_target()
123    }
124
125    #[must_use]
126    pub const fn focus(&self) -> MarkdownFocusPane {
127        self.focus
128    }
129
130    #[must_use]
131    pub const fn scroll_offset(&self) -> usize {
132        self.scroll
133    }
134
135    #[must_use]
136    pub const fn outline_scroll_offset(&self) -> usize {
137        self.outline_scroll
138    }
139
140    #[must_use]
141    pub const fn cursor_position(&self) -> Option<Position> {
142        self.cursor_position
143    }
144
145    #[must_use]
146    pub const fn is_dirty(&self) -> bool {
147        self.dirty
148    }
149
150    pub const fn mark_dirty(&mut self) {
151        self.dirty = true;
152    }
153
154    /// Returns the active renderer-neutral theme.
155    #[must_use]
156    pub const fn theme(&self) -> &ReviewTheme {
157        &self.theme
158    }
159
160    #[must_use]
161    pub const fn highlight_stats(&self) -> HighlightStats {
162        self.highlighter.stats()
163    }
164
165    /// Replaces the parsed snapshot and reconciles all existing comments.
166    pub fn set_document(&mut self, document: Arc<MarkdownDocument>) {
167        self.session.replace_document(document);
168        self.layout = None;
169        self.cursor_position = None;
170        self.scroll = 0;
171        self.follow_pending = true;
172        self.mark_dirty();
173    }
174
175    pub fn set_theme(&mut self, theme: ReviewTheme) {
176        self.theme_picker = None;
177        self.apply_theme(theme);
178    }
179
180    pub(crate) fn apply_theme(&mut self, theme: ReviewTheme) {
181        self.theme = theme;
182        self.mark_dirty();
183    }
184
185    #[must_use]
186    pub const fn options(&self) -> &ReviewOptions {
187        &self.options
188    }
189
190    #[must_use]
191    pub fn keybindings(&self) -> &[KeyBinding<MarkdownReviewCommand>] {
192        &self.keybindings
193    }
194
195    pub fn set_keybindings(&mut self, bindings: impl Into<Vec<KeyBinding<MarkdownReviewCommand>>>) {
196        self.keybindings = bindings.into();
197        self.help_scroll = 0;
198        self.mark_dirty();
199    }
200
201    pub fn set_options(&mut self, options: ReviewOptions) {
202        if matches!(
203            options.navigation,
204            NavigationPane::Hidden | NavigationPane::Width(0)
205        ) {
206            self.focus = MarkdownFocusPane::Document;
207        }
208        self.options = options;
209        self.clear_hit_regions();
210        self.request_follow();
211    }
212
213    #[must_use]
214    pub fn theme_choices(&self) -> &[ThemeChoice] {
215        &self.theme_choices
216    }
217
218    pub fn set_theme_choices(&mut self, themes: impl Into<Arc<[ThemeChoice]>>) {
219        if let Some(picker) = self.theme_picker.take() {
220            self.set_theme(picker.cancel());
221        }
222        self.theme_choices = themes.into();
223        self.mark_dirty();
224    }
225
226    pub(crate) fn ensure_layout(&mut self, width: u16) -> Arc<MarkdownVisualLayout> {
227        let key = self.layout_key(width);
228        if self
229            .layout
230            .as_ref()
231            .is_none_or(|cached| cached.key != key || cached.width != width)
232        {
233            self.layout = Some(CachedLayout {
234                key,
235                width,
236                layout: Arc::new(MarkdownVisualLayout::build(
237                    &self.session,
238                    width,
239                    &mut self.highlighter,
240                    &self.theme,
241                )),
242            });
243        }
244        Arc::clone(&self.layout.as_ref().expect("layout inserted above").layout)
245    }
246
247    fn layout_key(&self, width: u16) -> u64 {
248        let mut hasher = std::collections::hash_map::DefaultHasher::new();
249        self.document().source().hash(&mut hasher);
250        width.hash(&mut hasher);
251        self.theme.revision().hash(&mut hasher);
252        self.review().comments().iter().for_each(|comment| {
253            comment.id.hash(&mut hasher);
254            comment.body.hash(&mut hasher);
255            comment.outdated.hash(&mut hasher);
256            format!("{:?}", comment.anchor).hash(&mut hasher);
257        });
258        if let Some(draft) = self.session.draft() {
259            draft.target().hash(&mut hasher);
260            draft.body().hash(&mut hasher);
261            draft.cursor().hash(&mut hasher);
262        }
263        hasher.finish()
264    }
265
266    pub(crate) fn request_follow(&mut self) {
267        self.follow_pending = true;
268        self.mark_dirty();
269    }
270
271    pub(crate) fn follow_selection(&mut self, layout: &MarkdownVisualLayout) {
272        if !self.follow_pending {
273            return;
274        }
275        self.follow_pending = false;
276        let Some(target) = self.selected_target() else {
277            return;
278        };
279        let Some(row) = layout.focused_row(target, self.session.draft().is_some()) else {
280            return;
281        };
282        let height = self.last_height.max(1);
283        if row < self.scroll {
284            self.scroll = row;
285        } else if row >= self.scroll.saturating_add(height) {
286            self.scroll = row.saturating_sub(height - 1);
287        }
288    }
289
290    pub(crate) fn clear_hit_regions(&mut self) {
291        self.hit_regions.clear();
292        self.document_area = Rect::default();
293        self.outline_area = Rect::default();
294    }
295
296    pub(crate) fn set_cursor(&mut self, position: Option<Position>) {
297        self.cursor_position = position;
298    }
299}