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) {
172 self.theme_picker = None;
173 self.apply_theme(theme);
174 }
175
176 pub(crate) fn apply_theme(&mut self, theme: ReviewTheme) {
177 self.theme = theme;
178 self.mark_dirty();
179 }
180
181 #[must_use]
182 pub const fn options(&self) -> &ReviewOptions {
183 &self.options
184 }
185
186 #[must_use]
187 pub fn keybindings(&self) -> &[KeyBinding<MarkdownReviewCommand>] {
188 &self.keybindings
189 }
190
191 pub fn set_keybindings(&mut self, bindings: impl Into<Vec<KeyBinding<MarkdownReviewCommand>>>) {
192 self.keybindings = bindings.into();
193 self.help_scroll = 0;
194 self.mark_dirty();
195 }
196
197 pub fn set_options(&mut self, options: ReviewOptions) {
198 if matches!(
199 options.navigation,
200 NavigationPane::Hidden | NavigationPane::Width(0)
201 ) {
202 self.focus = MarkdownFocusPane::Document;
203 }
204 self.options = options;
205 self.hit_regions.clear();
206 self.request_follow();
207 }
208
209 #[must_use]
210 pub fn theme_choices(&self) -> &[ThemeChoice] {
211 &self.theme_choices
212 }
213
214 pub fn set_theme_choices(&mut self, themes: impl Into<Arc<[ThemeChoice]>>) {
215 if let Some(picker) = self.theme_picker.take() {
216 self.set_theme(picker.cancel());
217 }
218 self.theme_choices = themes.into();
219 self.mark_dirty();
220 }
221
222 pub(crate) fn ensure_layout(&mut self, width: u16) -> Arc<MarkdownVisualLayout> {
223 let key = self.layout_key(width);
224 if self
225 .layout
226 .as_ref()
227 .is_none_or(|cached| cached.key != key || cached.width != width)
228 {
229 self.layout = Some(CachedLayout {
230 key,
231 width,
232 layout: Arc::new(MarkdownVisualLayout::build(
233 &self.session,
234 width,
235 &mut self.highlighter,
236 &self.theme,
237 )),
238 });
239 }
240 Arc::clone(&self.layout.as_ref().expect("layout inserted above").layout)
241 }
242
243 fn layout_key(&self, width: u16) -> u64 {
244 let mut hasher = std::collections::hash_map::DefaultHasher::new();
245 self.document().source().hash(&mut hasher);
246 width.hash(&mut hasher);
247 self.theme.revision().hash(&mut hasher);
248 self.review().comments().iter().for_each(|comment| {
249 comment.id.hash(&mut hasher);
250 comment.body.hash(&mut hasher);
251 comment.outdated.hash(&mut hasher);
252 format!("{:?}", comment.anchor).hash(&mut hasher);
253 });
254 if let Some(draft) = self.session.draft() {
255 draft.target().hash(&mut hasher);
256 draft.body().hash(&mut hasher);
257 draft.cursor().hash(&mut hasher);
258 }
259 hasher.finish()
260 }
261
262 pub(crate) fn request_follow(&mut self) {
263 self.follow_pending = true;
264 self.mark_dirty();
265 }
266
267 pub(crate) fn follow_selection(&mut self, layout: &MarkdownVisualLayout) {
268 if !self.follow_pending {
269 return;
270 }
271 self.follow_pending = false;
272 let Some(target) = self.selected_target() else {
273 return;
274 };
275 let Some(row) = layout.focused_row(target, self.session.draft().is_some()) else {
276 return;
277 };
278 let height = self.last_height.max(1);
279 if row < self.scroll {
280 self.scroll = row;
281 } else if row >= self.scroll.saturating_add(height) {
282 self.scroll = row.saturating_sub(height - 1);
283 }
284 }
285
286 pub(crate) fn clear_hit_regions(&mut self) {
287 self.hit_regions.clear();
288 }
289
290 pub(crate) fn set_cursor(&mut self, position: Option<Position>) {
291 self.cursor_position = position;
292 }
293}