clankerdiff_ratatui/markdown_review/
state.rs1use 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#[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 #[must_use]
64 pub fn new(document: Arc<MarkdownDocument>) -> Self {
65 Self::with_theme(document, ReviewTheme::default())
66 }
67
68 #[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 #[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 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}