clankerdiff_ratatui/markdown_review/
state.rs1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
16pub enum MarkdownFocusPane {
17 #[default]
19 Document,
20 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#[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 #[must_use]
61 pub fn new(document: Arc<MarkdownDocument>) -> Self {
62 Self::with_theme(document, DiffTheme::default())
63 }
64
65 #[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 #[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 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 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}