Skip to main content

clankerdiff_ratatui/markdown_review/
state.rs

1use super::layout::MarkdownVisualLayout;
2use crate::theme_picker::ThemePicker;
3use clankerdiff_markdown::{
4    MarkdownDocument, MarkdownReview, MarkdownReviewSession, MarkdownTargetId,
5};
6use clankerdiff_syntax::{HighlightStats, SyntaxHighlighter};
7use clankerdiff_theme::DiffTheme;
8use ratatui::layout::{Position, Rect};
9use std::{
10    hash::{Hash, Hasher},
11    sync::Arc,
12};
13
14/// The pane receiving Markdown review navigation.
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
16pub enum MarkdownFocusPane {
17    /// The rendered document.
18    #[default]
19    Document,
20    /// The heading outline.
21    Outline,
22}
23
24#[derive(Debug, Clone, Copy)]
25pub(crate) struct MarkdownHitRegion {
26    pub area: Rect,
27    pub target: Option<MarkdownTargetId>,
28    pub outline: bool,
29}
30
31#[derive(Debug)]
32struct CachedLayout {
33    key: u64,
34    width: u16,
35    layout: Arc<MarkdownVisualLayout>,
36}
37
38/// Persistent state for [`crate::MarkdownReviewWidget`].
39#[derive(Debug)]
40pub struct MarkdownReviewState {
41    pub(crate) session: MarkdownReviewSession,
42    pub(crate) theme: DiffTheme,
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) cursor_position: Option<Position>,
52    pub(crate) help: bool,
53    pub(crate) theme_picker: Option<ThemePicker>,
54    pub(crate) dirty: bool,
55    pub(crate) follow_pending: bool,
56}
57
58impl MarkdownReviewState {
59    /// Creates ready state from an immutable parsed document.
60    #[must_use]
61    pub fn new(document: Arc<MarkdownDocument>) -> Self {
62        Self::with_theme(document, DiffTheme::default())
63    }
64
65    /// Creates state with an explicit shared neutral theme.
66    #[must_use]
67    pub fn with_theme(document: Arc<MarkdownDocument>, theme: DiffTheme) -> Self {
68        Self {
69            session: MarkdownReviewSession::new(document),
70            theme,
71            highlighter: SyntaxHighlighter::default(),
72            focus: MarkdownFocusPane::Document,
73            scroll: 0,
74            outline_scroll: 0,
75            outline_selected: 0,
76            last_height: 1,
77            layout: None,
78            hit_regions: Vec::new(),
79            cursor_position: None,
80            help: false,
81            theme_picker: None,
82            dirty: true,
83            follow_pending: true,
84        }
85    }
86
87    #[must_use]
88    pub const fn session(&self) -> &MarkdownReviewSession {
89        &self.session
90    }
91
92    pub const fn session_mut(&mut self) -> &mut MarkdownReviewSession {
93        &mut self.session
94    }
95
96    #[must_use]
97    pub const fn document(&self) -> &Arc<MarkdownDocument> {
98        self.session.document()
99    }
100
101    #[must_use]
102    pub const fn review(&self) -> &MarkdownReview {
103        self.session.review()
104    }
105
106    pub const fn review_mut(&mut self) -> &mut MarkdownReview {
107        self.session.review_mut()
108    }
109
110    #[must_use]
111    pub const fn selected_target(&self) -> Option<MarkdownTargetId> {
112        self.session.selected_target()
113    }
114
115    #[must_use]
116    pub const fn focus(&self) -> MarkdownFocusPane {
117        self.focus
118    }
119
120    #[must_use]
121    pub const fn scroll_offset(&self) -> usize {
122        self.scroll
123    }
124
125    #[must_use]
126    pub const fn outline_scroll_offset(&self) -> usize {
127        self.outline_scroll
128    }
129
130    #[must_use]
131    pub const fn cursor_position(&self) -> Option<Position> {
132        self.cursor_position
133    }
134
135    #[must_use]
136    pub const fn is_dirty(&self) -> bool {
137        self.dirty
138    }
139
140    pub const fn mark_dirty(&mut self) {
141        self.dirty = true;
142    }
143
144    /// Returns the active renderer-neutral theme.
145    #[must_use]
146    pub const fn theme(&self) -> &DiffTheme {
147        &self.theme
148    }
149
150    #[must_use]
151    pub const fn highlight_stats(&self) -> HighlightStats {
152        self.highlighter.stats()
153    }
154
155    /// Replaces the parsed snapshot and reconciles all existing comments.
156    pub fn set_document(&mut self, document: Arc<MarkdownDocument>) {
157        self.session.replace_document(document);
158        self.layout = None;
159        self.cursor_position = None;
160        self.scroll = 0;
161        self.follow_pending = true;
162        self.mark_dirty();
163    }
164
165    /// Changes the theme and invalidates syntax highlighting.
166    pub fn set_theme(&mut self, theme: DiffTheme) {
167        self.theme = theme;
168        self.highlighter.clear_cache();
169        self.mark_dirty();
170    }
171
172    pub(crate) fn ensure_layout(&mut self, width: u16) -> Arc<MarkdownVisualLayout> {
173        let key = self.layout_key(width);
174        if self
175            .layout
176            .as_ref()
177            .is_none_or(|cached| cached.key != key || cached.width != width)
178        {
179            self.layout = Some(CachedLayout {
180                key,
181                width,
182                layout: Arc::new(MarkdownVisualLayout::build(
183                    &self.session,
184                    width,
185                    &mut self.highlighter,
186                    &self.theme,
187                )),
188            });
189        }
190        Arc::clone(&self.layout.as_ref().expect("layout inserted above").layout)
191    }
192
193    fn layout_key(&self, width: u16) -> u64 {
194        let mut hasher = std::collections::hash_map::DefaultHasher::new();
195        self.document().source().hash(&mut hasher);
196        width.hash(&mut hasher);
197        self.theme.revision().hash(&mut hasher);
198        self.review().comments().iter().for_each(|comment| {
199            comment.id.hash(&mut hasher);
200            comment.body.hash(&mut hasher);
201            comment.outdated.hash(&mut hasher);
202            format!("{:?}", comment.anchor).hash(&mut hasher);
203        });
204        if let Some(draft) = self.session.draft() {
205            draft.target().hash(&mut hasher);
206            draft.body().hash(&mut hasher);
207            draft.cursor().hash(&mut hasher);
208        }
209        hasher.finish()
210    }
211
212    pub(crate) fn request_follow(&mut self) {
213        self.follow_pending = true;
214        self.mark_dirty();
215    }
216
217    pub(crate) fn follow_selection(&mut self, layout: &MarkdownVisualLayout) {
218        if !self.follow_pending {
219            return;
220        }
221        self.follow_pending = false;
222        let Some(target) = self.selected_target() else {
223            return;
224        };
225        let Some(row) = layout.row_for_target(target) else {
226            return;
227        };
228        let height = self.last_height.max(1);
229        if row < self.scroll {
230            self.scroll = row;
231        } else if row >= self.scroll.saturating_add(height) {
232            self.scroll = row.saturating_sub(height - 1);
233        }
234    }
235
236    pub(crate) fn selected_outline_target(&self) -> Option<MarkdownTargetId> {
237        self.document()
238            .outline()
239            .get(self.outline_selected)
240            .map(|heading| heading.target_id)
241    }
242
243    pub(crate) fn clear_hit_regions(&mut self) {
244        self.hit_regions.clear();
245    }
246
247    pub(crate) fn set_cursor(&mut self, position: Option<Position>) {
248        self.cursor_position = position;
249    }
250}