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) cursor_position: Option<Position>,
52 pub(crate) help: bool,
53 pub(crate) help_scroll: usize,
54 pub(crate) theme_picker: Option<ThemePicker>,
55 pub(crate) dirty: bool,
56 pub(crate) follow_pending: bool,
57}
58
59impl MarkdownReviewState {
60 #[must_use]
62 pub fn new(document: Arc<MarkdownDocument>) -> Self {
63 Self::with_theme(document, ReviewTheme::default())
64 }
65
66 #[must_use]
68 pub fn with_theme(document: Arc<MarkdownDocument>, theme: ReviewTheme) -> Self {
69 Self {
70 session: MarkdownReviewSession::new(document),
71 options: ReviewOptions::default(),
72 capabilities: ReviewCapabilities::default(),
73 keybindings: default_markdown_keybindings(),
74 theme_choices: Arc::from([]),
75 theme,
76 highlighter: SyntaxHighlighter::default(),
77 focus: MarkdownFocusPane::Document,
78 scroll: 0,
79 outline_scroll: 0,
80 outline_selected: 0,
81 last_height: 1,
82 layout: None,
83 hit_regions: Vec::new(),
84 cursor_position: None,
85 help: false,
86 help_scroll: 0,
87 theme_picker: None,
88 dirty: true,
89 follow_pending: true,
90 }
91 }
92
93 #[must_use]
94 pub const fn session(&self) -> &MarkdownReviewSession {
95 &self.session
96 }
97
98 pub const fn session_mut(&mut self) -> &mut MarkdownReviewSession {
99 &mut self.session
100 }
101
102 #[must_use]
103 pub const fn document(&self) -> &Arc<MarkdownDocument> {
104 self.session.document()
105 }
106
107 #[must_use]
108 pub const fn review(&self) -> &MarkdownReview {
109 self.session.review()
110 }
111
112 pub const fn review_mut(&mut self) -> &mut MarkdownReview {
113 self.session.review_mut()
114 }
115
116 #[must_use]
117 pub const fn selected_target(&self) -> Option<MarkdownTargetId> {
118 self.session.selected_target()
119 }
120
121 #[must_use]
122 pub const fn focus(&self) -> MarkdownFocusPane {
123 self.focus
124 }
125
126 #[must_use]
127 pub const fn scroll_offset(&self) -> usize {
128 self.scroll
129 }
130
131 #[must_use]
132 pub const fn outline_scroll_offset(&self) -> usize {
133 self.outline_scroll
134 }
135
136 #[must_use]
137 pub const fn cursor_position(&self) -> Option<Position> {
138 self.cursor_position
139 }
140
141 #[must_use]
142 pub const fn is_dirty(&self) -> bool {
143 self.dirty
144 }
145
146 pub const fn mark_dirty(&mut self) {
147 self.dirty = true;
148 }
149
150 #[must_use]
152 pub const fn theme(&self) -> &ReviewTheme {
153 &self.theme
154 }
155
156 #[must_use]
157 pub const fn highlight_stats(&self) -> HighlightStats {
158 self.highlighter.stats()
159 }
160
161 pub fn set_document(&mut self, document: Arc<MarkdownDocument>) {
163 self.session.replace_document(document);
164 self.layout = None;
165 self.cursor_position = None;
166 self.scroll = 0;
167 self.follow_pending = true;
168 self.mark_dirty();
169 }
170
171 pub fn set_theme(&mut self, theme: ReviewTheme) {
173 self.theme_picker = None;
174 self.apply_theme(theme);
175 }
176
177 pub(crate) fn apply_theme(&mut self, theme: ReviewTheme) {
178 self.theme = theme;
179 self.highlighter.clear_cache();
180 self.mark_dirty();
181 }
182
183 #[must_use]
184 pub const fn options(&self) -> &ReviewOptions {
185 &self.options
186 }
187
188 #[must_use]
189 pub fn keybindings(&self) -> &[KeyBinding<MarkdownReviewCommand>] {
190 &self.keybindings
191 }
192
193 pub fn set_keybindings(&mut self, bindings: impl Into<Vec<KeyBinding<MarkdownReviewCommand>>>) {
194 self.keybindings = bindings.into();
195 self.help_scroll = 0;
196 self.mark_dirty();
197 }
198
199 pub fn set_options(&mut self, options: ReviewOptions) {
200 if matches!(
201 options.navigation,
202 NavigationPane::Hidden | NavigationPane::Width(0)
203 ) {
204 self.focus = MarkdownFocusPane::Document;
205 }
206 self.options = options;
207 self.hit_regions.clear();
208 self.request_follow();
209 }
210
211 #[must_use]
212 pub fn theme_choices(&self) -> &[ThemeChoice] {
213 &self.theme_choices
214 }
215
216 pub fn set_theme_choices(&mut self, themes: impl Into<Arc<[ThemeChoice]>>) {
217 if let Some(picker) = self.theme_picker.take() {
218 self.set_theme(picker.cancel());
219 }
220 self.theme_choices = themes.into();
221 self.mark_dirty();
222 }
223
224 pub(crate) fn ensure_layout(&mut self, width: u16) -> Arc<MarkdownVisualLayout> {
225 let key = self.layout_key(width);
226 if self
227 .layout
228 .as_ref()
229 .is_none_or(|cached| cached.key != key || cached.width != width)
230 {
231 self.layout = Some(CachedLayout {
232 key,
233 width,
234 layout: Arc::new(MarkdownVisualLayout::build(
235 &self.session,
236 width,
237 &mut self.highlighter,
238 &self.theme,
239 )),
240 });
241 }
242 Arc::clone(&self.layout.as_ref().expect("layout inserted above").layout)
243 }
244
245 fn layout_key(&self, width: u16) -> u64 {
246 let mut hasher = std::collections::hash_map::DefaultHasher::new();
247 self.document().source().hash(&mut hasher);
248 width.hash(&mut hasher);
249 self.theme.revision().hash(&mut hasher);
250 self.review().comments().iter().for_each(|comment| {
251 comment.id.hash(&mut hasher);
252 comment.body.hash(&mut hasher);
253 comment.outdated.hash(&mut hasher);
254 format!("{:?}", comment.anchor).hash(&mut hasher);
255 });
256 if let Some(draft) = self.session.draft() {
257 draft.target().hash(&mut hasher);
258 draft.body().hash(&mut hasher);
259 draft.cursor().hash(&mut hasher);
260 }
261 hasher.finish()
262 }
263
264 pub(crate) fn request_follow(&mut self) {
265 self.follow_pending = true;
266 self.mark_dirty();
267 }
268
269 pub(crate) fn follow_selection(&mut self, layout: &MarkdownVisualLayout) {
270 if !self.follow_pending {
271 return;
272 }
273 self.follow_pending = false;
274 let Some(target) = self.selected_target() else {
275 return;
276 };
277 let Some(row) = layout.focused_row(target, self.session.draft().is_some()) else {
278 return;
279 };
280 let height = self.last_height.max(1);
281 if row < self.scroll {
282 self.scroll = row;
283 } else if row >= self.scroll.saturating_add(height) {
284 self.scroll = row.saturating_sub(height - 1);
285 }
286 }
287
288 pub(crate) fn clear_hit_regions(&mut self) {
289 self.hit_regions.clear();
290 }
291
292 pub(crate) fn set_cursor(&mut self, position: Option<Position>) {
293 self.cursor_position = position;
294 }
295}