Skip to main content

clankerdiff_ratatui/
state.rs

1use crate::{
2    DiffReviewCommand, InteractionPhase, KeyBinding, NavigationPane, ReviewOptions, ThemeChoice,
3    default_diff_keybindings,
4    drawer::{DrawerEntry, DrawerTree},
5    patch_layout::PatchVisualLayout,
6    theme_picker::ThemePicker,
7};
8use clankerdiff_core::{
9    DiffDocument, DiffPresentation, DiffScope, DiffSide, FileStatus, Layout, RepositoryAction,
10    RevealAmount, Review, ReviewCapabilities, ReviewSession, StageState, ViewMode,
11};
12use clankerdiff_syntax::{HighlightStats, SyntaxHighlighter};
13use clankerdiff_theme::ReviewTheme;
14use ratatui::layout::{Position, Rect};
15use std::{
16    collections::hash_map::DefaultHasher,
17    hash::{Hash, Hasher},
18    sync::Arc,
19};
20
21pub(crate) const SPLIT_BREAKPOINT: u16 = 96;
22
23pub use clankerdiff_core::FocusPane;
24
25/// Current host-provided document state.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum DiffReviewStatus {
28    /// A host is loading a snapshot.
29    Loading,
30    /// A document is ready for review.
31    Ready,
32    /// Loading failed with a host-provided message.
33    Error(String),
34}
35
36#[derive(Debug, Clone, Copy, Default)]
37pub(crate) struct HitLayout {
38    pub drawer: Rect,
39    pub drawer_stage_column: Option<u16>,
40    pub patch: Rect,
41}
42
43#[derive(Debug, Clone, Copy, Default)]
44enum DrawerFollow {
45    #[default]
46    Pending,
47    Settled,
48}
49
50#[derive(Debug)]
51pub(crate) enum RepositoryPrompt {
52    Commit {
53        message: String,
54    },
55    Discard {
56        path: clankerdiff_core::RepoPath,
57        status: FileStatus,
58    },
59}
60
61/// Status of the most recent repository mutation requested by the UI.
62#[derive(Debug, Clone, PartialEq, Eq, Default)]
63pub enum RepositoryOperationStatus {
64    #[default]
65    Idle,
66    Pending,
67    Error(String),
68}
69
70#[derive(Debug)]
71struct CachedPatchLayout {
72    key: u64,
73    layout: Arc<PatchVisualLayout>,
74}
75
76/// Persistent state for [`crate::DiffReviewWidget`].
77#[derive(Debug)]
78pub struct DiffReviewState {
79    pub(crate) session: ReviewSession,
80    pub(crate) scope: DiffScope,
81    pub(crate) options: ReviewOptions,
82    pub(crate) capabilities: ReviewCapabilities,
83    pub(crate) keybindings: Vec<KeyBinding<DiffReviewCommand>>,
84    pub(crate) theme_choices: Arc<[ThemeChoice]>,
85    pub(crate) theme: ReviewTheme,
86    pub(crate) highlighter: SyntaxHighlighter,
87    pub(crate) status: DiffReviewStatus,
88    pub(crate) focus: FocusPane,
89    pub(crate) drawer: DrawerTree,
90    pub(crate) drawer_selected: usize,
91    pub(crate) drawer_scroll: usize,
92    pub(crate) drawer_height: usize,
93    drawer_follow: DrawerFollow,
94    pub(crate) scroll: usize,
95    pub(crate) last_height: usize,
96    pub(crate) presentation_width: u16,
97    patch_layout: Option<CachedPatchLayout>,
98    pub(crate) help: bool,
99    pub(crate) help_scroll: usize,
100    pub(crate) theme_picker: Option<ThemePicker>,
101    pub(crate) repository_prompt: Option<RepositoryPrompt>,
102    pub(crate) repository_status: RepositoryOperationStatus,
103    pub(crate) background_error: Option<String>,
104    deferred_document: Option<Arc<DiffDocument>>,
105    deferred_scope: Option<DiffScope>,
106    pub(crate) hit_layout: HitLayout,
107    pub(crate) visible_rows: Vec<(u16, usize)>,
108    pub(crate) cursor_position: Option<Position>,
109    pub(crate) follow_pending: bool,
110    pub(crate) dirty: bool,
111}
112
113impl DiffReviewState {
114    /// Creates ready state from an immutable document snapshot.
115    #[must_use]
116    pub fn new(document: Arc<DiffDocument>) -> Self {
117        Self::with_theme(document, ReviewTheme::default())
118    }
119
120    /// Creates ready state with a shared neutral theme.
121    #[must_use]
122    pub fn with_theme(document: Arc<DiffDocument>, theme: ReviewTheme) -> Self {
123        let drawer = DrawerTree::new(&document);
124        let drawer_selected = drawer.position_of_file(0).unwrap_or(0);
125        let mut state = Self {
126            session: ReviewSession::new(document),
127            scope: DiffScope::Both,
128            options: ReviewOptions::default(),
129            capabilities: ReviewCapabilities::default(),
130            keybindings: default_diff_keybindings(),
131            theme_choices: Arc::from([]),
132            theme,
133            highlighter: SyntaxHighlighter::default(),
134            status: DiffReviewStatus::Ready,
135            focus: FocusPane::Files,
136            drawer,
137            drawer_selected,
138            drawer_scroll: 0,
139            drawer_height: 1,
140            drawer_follow: DrawerFollow::Pending,
141            scroll: 0,
142            last_height: 0,
143            presentation_width: 0,
144            patch_layout: None,
145            help: false,
146            help_scroll: 0,
147            theme_picker: None,
148            repository_prompt: None,
149            repository_status: RepositoryOperationStatus::Idle,
150            background_error: None,
151            deferred_document: None,
152            deferred_scope: None,
153            hit_layout: HitLayout::default(),
154            visible_rows: Vec::new(),
155            cursor_position: None,
156            follow_pending: true,
157            dirty: true,
158        };
159        state.scroll_to_selected_file();
160        state
161    }
162
163    /// Creates loading state with an empty placeholder document.
164    #[must_use]
165    pub fn loading() -> Self {
166        let mut state = Self::new(Arc::new(DiffDocument::empty()));
167        state.status = DiffReviewStatus::Loading;
168        state
169    }
170
171    #[must_use]
172    pub const fn session(&self) -> &ReviewSession {
173        &self.session
174    }
175
176    pub const fn session_mut(&mut self) -> &mut ReviewSession {
177        &mut self.session
178    }
179
180    #[must_use]
181    pub const fn scope(&self) -> DiffScope {
182        self.scope
183    }
184
185    pub fn set_scope(&mut self, scope: DiffScope) {
186        if self.interaction_phase() == InteractionPhase::Browse {
187            self.scope = scope;
188            self.mark_dirty();
189        } else {
190            self.deferred_scope = Some(scope);
191        }
192    }
193
194    /// Returns the current immutable snapshot.
195    #[must_use]
196    pub const fn document(&self) -> &Arc<DiffDocument> {
197        self.session.document()
198    }
199
200    /// Returns the structured review.
201    #[must_use]
202    pub const fn review(&self) -> &Review {
203        self.session.review()
204    }
205
206    /// Returns mutable review access for host-driven operations.
207    pub const fn review_mut(&mut self) -> &mut Review {
208        self.session.review_mut()
209    }
210
211    #[must_use]
212    pub const fn presentation(&self) -> &DiffPresentation {
213        self.session.presentation()
214    }
215
216    /// Returns the current load status.
217    #[must_use]
218    pub const fn status(&self) -> &DiffReviewStatus {
219        &self.status
220    }
221
222    /// Returns the focused pane.
223    #[must_use]
224    pub const fn focus(&self) -> FocusPane {
225        self.focus
226    }
227
228    /// Returns the selected file index, when the document has files.
229    #[must_use]
230    pub fn selected_file(&self) -> Option<usize> {
231        self.session.selected_file()
232    }
233
234    /// Returns the selected presentation-row index, when rows exist.
235    #[must_use]
236    pub fn selected_row(&self) -> Option<usize> {
237        self.session.selected_row()
238    }
239
240    #[must_use]
241    pub const fn selected_side(&self) -> DiffSide {
242        self.session.selected_side()
243    }
244
245    /// Returns the active renderer-neutral theme.
246    #[must_use]
247    pub const fn theme(&self) -> &ReviewTheme {
248        &self.theme
249    }
250
251    /// Returns syntax cache and work counters.
252    #[must_use]
253    pub const fn highlight_stats(&self) -> HighlightStats {
254        self.highlighter.stats()
255    }
256
257    /// Returns the first visible rendered-row index in the selected file.
258    #[must_use]
259    pub const fn scroll_offset(&self) -> usize {
260        self.scroll
261    }
262
263    /// Returns whether anything since the last frame changed what a redraw
264    /// would show.
265    ///
266    /// Hosts may skip drawing [`crate::DiffReviewWidget`] while this is false,
267    /// which keeps pointer motion in a terminal reporting all mouse movement
268    /// from costing a frame each. Rendering the widget clears it.
269    #[must_use]
270    pub const fn is_dirty(&self) -> bool {
271        self.dirty
272    }
273
274    /// Marks the state as needing a redraw, for changes the widget cannot
275    /// observe on its own, such as a terminal resize.
276    pub const fn mark_dirty(&mut self) {
277        self.dirty = true;
278    }
279
280    /// Returns the requested view mode.
281    #[must_use]
282    pub const fn view_mode(&self) -> ViewMode {
283        self.session.view_mode()
284    }
285
286    #[must_use]
287    pub const fn layout(&self) -> Layout {
288        self.session.layout()
289    }
290
291    /// Returns the terminal cursor position for an active comment draft.
292    ///
293    /// Hosts may pass this to `Frame::set_cursor_position` after rendering.
294    #[must_use]
295    pub const fn cursor_position(&self) -> Option<Position> {
296        self.cursor_position
297    }
298
299    /// Replaces the complete document while retaining and reconciling review comments.
300    pub fn set_document(&mut self, document: Arc<DiffDocument>) {
301        if self.interaction_phase() == InteractionPhase::Browse {
302            self.install_document(document);
303        } else {
304            self.deferred_document = Some(document);
305        }
306    }
307
308    pub(crate) fn install_deferred(&mut self) {
309        if self.interaction_phase() != InteractionPhase::Browse {
310            return;
311        }
312        if let Some(document) = self.deferred_document.take() {
313            self.install_document(document);
314        }
315        if let Some(scope) = self.deferred_scope.take() {
316            self.set_scope(scope);
317        }
318    }
319
320    fn install_document(&mut self, document: Arc<DiffDocument>) {
321        self.session.set_document(document);
322        self.status = DiffReviewStatus::Ready;
323        self.cursor_position = None;
324        self.mark_dirty();
325        let document = self.document().clone();
326        self.drawer.rebuild(&document);
327        if let Some(selected) = self.session.selected_file() {
328            self.drawer.expand_file(&document, selected);
329            self.drawer_selected = self.drawer.position_of_file(selected).unwrap_or(0);
330        } else {
331            self.drawer_selected = 0;
332        }
333        self.follow_drawer_selection();
334        self.request_follow();
335    }
336
337    /// Marks the state as waiting for a host snapshot.
338    pub fn set_loading(&mut self) {
339        self.status = DiffReviewStatus::Loading;
340        self.deferred_document = None;
341        self.deferred_scope = None;
342        self.session.cancel_draft();
343        self.cursor_position = None;
344        self.mark_dirty();
345    }
346
347    /// Shows a host-provided loading error.
348    pub fn set_error(&mut self, message: impl Into<String>) {
349        self.status = DiffReviewStatus::Error(message.into());
350        self.deferred_document = None;
351        self.deferred_scope = None;
352        self.session.cancel_draft();
353        self.cursor_position = None;
354        self.mark_dirty();
355    }
356
357    /// Marks a repository mutation as in flight while retaining the current snapshot.
358    pub fn set_repository_pending(&mut self) {
359        self.repository_status = RepositoryOperationStatus::Pending;
360        self.repository_prompt = None;
361        self.mark_dirty();
362    }
363
364    /// Clears a pending repository mutation without replacing the snapshot.
365    pub fn clear_repository_pending(&mut self) {
366        self.repository_status = RepositoryOperationStatus::Idle;
367        self.mark_dirty();
368    }
369
370    /// Shows a repository mutation error while retaining the current snapshot.
371    pub fn set_repository_error(&mut self, message: impl Into<String>) {
372        self.repository_status = RepositoryOperationStatus::Error(message.into());
373        self.repository_prompt = None;
374        self.mark_dirty();
375    }
376
377    /// Reconciles background refresh health independently of command completion.
378    pub fn set_background_error(&mut self, message: Option<String>) {
379        if self.background_error != message {
380            self.background_error = message;
381            self.mark_dirty();
382        }
383    }
384
385    /// Whether a host command is still in flight.
386    #[must_use]
387    pub fn repository_pending(&self) -> bool {
388        matches!(self.repository_status, RepositoryOperationStatus::Pending)
389    }
390
391    /// The command error, or otherwise the current background refresh failure.
392    #[must_use]
393    pub fn repository_error(&self) -> Option<&str> {
394        match &self.repository_status {
395            RepositoryOperationStatus::Error(message) => Some(message),
396            _ => self.background_error.as_deref(),
397        }
398    }
399
400    pub(crate) fn toggle_stage_action(&self) -> Option<RepositoryAction> {
401        let entry = self.drawer.entry(self.drawer_selected)?;
402        let document = self.document();
403        let state = DrawerTree::stage_state_for_entry(document, entry);
404        let paths = DrawerTree::paths_for_entry(document, entry);
405        if paths.is_empty() {
406            return None;
407        }
408        Some(if state == StageState::Staged {
409            RepositoryAction::UnstagePaths(paths)
410        } else {
411            RepositoryAction::StagePaths(paths)
412        })
413    }
414
415    pub(crate) fn begin_commit(&mut self) {
416        self.repository_prompt = Some(RepositoryPrompt::Commit {
417            message: String::new(),
418        });
419    }
420
421    pub(crate) fn begin_discard(&mut self) {
422        let Some(file) = self
423            .selected_file()
424            .and_then(|index| self.document().files.get(index))
425        else {
426            return;
427        };
428        self.repository_prompt = Some(RepositoryPrompt::Discard {
429            path: file.path.clone(),
430            status: file.status,
431        });
432    }
433
434    pub fn set_theme(&mut self, theme: ReviewTheme) {
435        self.theme_picker = None;
436        self.apply_theme(theme);
437    }
438
439    pub(crate) fn apply_theme(&mut self, theme: ReviewTheme) {
440        self.theme = theme;
441        self.mark_dirty();
442    }
443
444    #[must_use]
445    pub const fn options(&self) -> &ReviewOptions {
446        &self.options
447    }
448
449    #[must_use]
450    pub fn keybindings(&self) -> &[KeyBinding<DiffReviewCommand>] {
451        &self.keybindings
452    }
453
454    pub fn set_keybindings(&mut self, bindings: impl Into<Vec<KeyBinding<DiffReviewCommand>>>) {
455        self.keybindings = bindings.into();
456        self.help_scroll = 0;
457        self.mark_dirty();
458    }
459
460    pub fn set_options(&mut self, options: ReviewOptions) {
461        if matches!(
462            options.navigation,
463            NavigationPane::Hidden | NavigationPane::Width(0)
464        ) {
465            self.focus = FocusPane::Diff;
466        }
467        self.options = options;
468        self.hit_layout = HitLayout::default();
469        self.request_follow();
470    }
471
472    #[must_use]
473    pub fn theme_choices(&self) -> &[ThemeChoice] {
474        &self.theme_choices
475    }
476
477    pub fn set_theme_choices(&mut self, themes: impl Into<Arc<[ThemeChoice]>>) {
478        if let Some(picker) = self.theme_picker.take() {
479            self.set_theme(picker.cancel());
480        }
481        self.theme_choices = themes.into();
482        self.mark_dirty();
483    }
484
485    pub(crate) fn select_file(&mut self, index: usize) -> bool {
486        if !self.session.select_file(index) {
487            return false;
488        }
489        self.drawer.expand_file(self.session.document(), index);
490        self.drawer_selected = self.drawer.position_of_file(index).unwrap_or(0);
491        self.follow_drawer_selection();
492        self.scroll_to_selected_file();
493        true
494    }
495
496    fn finish_projection_change(&mut self, changed: bool) -> bool {
497        if changed {
498            self.request_follow();
499        }
500        changed
501    }
502
503    pub fn reveal_selected_gap(&mut self, amount: RevealAmount) -> bool {
504        let previous = (
505            self.session.selected_file_range(),
506            self.session.selected_row(),
507            self.session.selected_side(),
508        );
509        self.session.reveal_selected_gap(amount);
510        let changed = previous
511            != (
512                self.session.selected_file_range(),
513                self.session.selected_row(),
514                self.session.selected_side(),
515            );
516        self.finish_projection_change(changed)
517    }
518
519    pub fn toggle_full_file(&mut self) -> bool {
520        let changed = self.session.toggle_full_file();
521        self.finish_projection_change(changed)
522    }
523
524    /// Selects automatic, unified, or split presentation.
525    pub fn set_view_mode(&mut self, mode: ViewMode) {
526        if self.session.set_view_mode(mode) {
527            self.scroll_to_selected_file();
528        }
529    }
530
531    /// Clears all queued comments and any active draft.
532    pub fn clear_review(&mut self) {
533        self.session.clear_review();
534        self.cursor_position = None;
535        self.mark_dirty();
536    }
537
538    pub(crate) fn ensure_presentation(&mut self, width: u16) {
539        let width_changed = self.presentation_width != width;
540        self.presentation_width = width;
541        if self.session.set_split_when_auto(width >= SPLIT_BREAKPOINT) {
542            self.scroll_to_selected_file();
543        } else if width_changed {
544            self.request_follow();
545        }
546    }
547
548    pub(crate) fn scroll_to_selected_file(&mut self) {
549        self.scroll = 0;
550        self.request_follow();
551    }
552
553    pub(crate) fn move_drawer_entry(&mut self, delta: isize) {
554        let last = self.drawer.entries().len().saturating_sub(1);
555        self.drawer_selected = offset(self.drawer_selected, delta, last);
556        if let Some(DrawerEntry::File { index, .. }) = self.drawer.entry(self.drawer_selected) {
557            let index = *index;
558            if self.session.select_file(index) {
559                self.scroll_to_selected_file();
560            }
561        }
562        self.follow_drawer_selection();
563    }
564
565    pub(crate) fn select_drawer_entry(&mut self, index: usize) {
566        if index >= self.drawer.entries().len() {
567            return;
568        }
569        self.drawer_selected = index;
570        if let Some(DrawerEntry::File { index, .. }) = self.drawer.entry(index) {
571            let index = *index;
572            if self.session.select_file(index) {
573                self.scroll_to_selected_file();
574            }
575        }
576        self.follow_drawer_selection();
577    }
578
579    /// Expands the selected directory, or selects the current file. Returns
580    /// whether the selected entry was a directory.
581    pub(crate) fn expand_or_open_drawer_entry(&mut self) -> bool {
582        match self.drawer.entry(self.drawer_selected).cloned() {
583            Some(DrawerEntry::Directory { path, .. }) => {
584                self.drawer.expand(&path);
585                self.drawer_selected = self.drawer.position_of_directory(&path).unwrap_or(0);
586                self.follow_drawer_selection();
587                self.mark_dirty();
588                true
589            }
590            Some(DrawerEntry::File { index, .. }) => {
591                if self.session.select_file(index) {
592                    self.scroll_to_selected_file();
593                }
594                false
595            }
596            None => false,
597        }
598    }
599
600    pub(crate) fn collapse_drawer_entry(&mut self) {
601        let Some(DrawerEntry::Directory { path, .. }) =
602            self.drawer.entry(self.drawer_selected).cloned()
603        else {
604            return;
605        };
606        self.drawer.collapse(&path);
607        self.drawer_selected = self.drawer.position_of_directory(&path).unwrap_or(0);
608        self.follow_drawer_selection();
609        self.mark_dirty();
610    }
611
612    fn follow_drawer_selection(&mut self) {
613        self.drawer_follow = DrawerFollow::Pending;
614        if self.drawer_selected < self.drawer_scroll {
615            self.drawer_scroll = self.drawer_selected;
616        } else if self.drawer_selected >= self.drawer_scroll.saturating_add(self.drawer_height) {
617            self.drawer_scroll = self
618                .drawer_selected
619                .saturating_sub(self.drawer_height.saturating_sub(1));
620        }
621    }
622
623    pub(crate) fn take_drawer_follow_request(&mut self) -> bool {
624        matches!(
625            std::mem::replace(&mut self.drawer_follow, DrawerFollow::Settled),
626            DrawerFollow::Pending
627        )
628    }
629
630    pub(crate) fn move_row(&mut self, delta: isize) {
631        let selected = self.session.selected_row();
632        let side = self.session.selected_side();
633        self.session.move_row(delta);
634        if self.session.selected_row() != selected || self.session.selected_side() != side {
635            self.request_follow();
636        }
637    }
638
639    pub(crate) fn select_boundary(&mut self, end: bool) {
640        self.session.select_boundary(end);
641        self.request_follow();
642    }
643
644    /// Scrolls the patch viewport without moving the selection. The selection
645    /// may leave the viewport; the next selection move brings it back.
646    pub(crate) fn scroll_patch(&mut self, delta: isize) {
647        let Some(layout) = self.patch_visual_layout() else {
648            return;
649        };
650        if layout.is_empty() {
651            return;
652        }
653        let target = if delta.is_negative() {
654            self.scroll.saturating_sub(delta.unsigned_abs())
655        } else {
656            self.scroll.saturating_add(delta.unsigned_abs())
657        };
658        let last = layout.len().saturating_sub(self.last_height.max(1));
659        let clamped = target.min(last);
660        self.follow_pending = false;
661        if clamped != self.scroll {
662            self.scroll = clamped;
663            self.mark_dirty();
664        }
665    }
666
667    /// Scrolls the file drawer without moving the selected file.
668    pub(crate) fn scroll_drawer(&mut self, delta: isize) {
669        let last = self
670            .drawer
671            .entries()
672            .len()
673            .saturating_sub(self.drawer_height);
674        let target = if delta.is_negative() {
675            self.drawer_scroll.saturating_sub(delta.unsigned_abs())
676        } else {
677            self.drawer_scroll.saturating_add(delta.unsigned_abs())
678        };
679        let clamped = target.min(last);
680        self.drawer_follow = DrawerFollow::Settled;
681        if clamped != self.drawer_scroll {
682            self.drawer_scroll = clamped;
683            self.mark_dirty();
684        }
685    }
686
687    pub(crate) fn page(&mut self, delta: isize) {
688        let height = isize::try_from(self.last_height.max(1)).unwrap_or(isize::MAX);
689        self.scroll_patch(delta.saturating_mul(height));
690    }
691
692    /// Brings the selection back into view against the height the last frame
693    /// measured, and asks the next frame to redo it once it knows its own.
694    /// Before any frame has drawn there is no height to work from, so the
695    /// request only carries over.
696    pub(crate) fn request_follow(&mut self) {
697        self.follow_pending = true;
698        self.mark_dirty();
699        self.follow_selection();
700    }
701
702    pub(crate) fn take_follow_request(&mut self) -> bool {
703        std::mem::take(&mut self.follow_pending)
704    }
705
706    pub(crate) fn follow_selection(&mut self) {
707        if self.last_height == 0 {
708            return;
709        }
710        let Some(selected) = self.session.selected_row() else {
711            return;
712        };
713        let draft = self.session.draft().is_some();
714        let Some(layout) = self.patch_visual_layout() else {
715            return;
716        };
717        let Some(target) = layout.focused_visual_row(selected, draft) else {
718            return;
719        };
720        let height = self.last_height.max(1);
721        if target < self.scroll {
722            self.scroll = target;
723        } else if target >= self.scroll.saturating_add(height) {
724            self.scroll = target.saturating_sub(height.saturating_sub(1));
725        }
726        self.scroll = self.scroll.min(layout.len().saturating_sub(height));
727    }
728
729    pub(crate) fn patch_visual_layout(&mut self) -> Option<Arc<PatchVisualLayout>> {
730        let range = self.session.selected_file_range()?;
731        let key = self.patch_layout_key(&range);
732        let rebuild = self
733            .patch_layout
734            .as_ref()
735            .is_none_or(|cached| cached.key != key);
736        if rebuild {
737            self.patch_layout = Some(CachedPatchLayout {
738                key,
739                layout: Arc::new(PatchVisualLayout::new(
740                    &self.session,
741                    range,
742                    self.presentation_width,
743                )),
744            });
745        }
746        self.patch_layout
747            .as_ref()
748            .map(|cached| cached.layout.clone())
749    }
750
751    fn patch_layout_key(&self, range: &std::ops::Range<usize>) -> u64 {
752        let mut hasher = DefaultHasher::new();
753        (Arc::as_ptr(self.document()) as usize).hash(&mut hasher);
754        self.presentation_width.hash(&mut hasher);
755        self.layout().is_split().hash(&mut hasher);
756        self.session.projection_revision().hash(&mut hasher);
757        range.start.hash(&mut hasher);
758        range.end.hash(&mut hasher);
759        for comment in self.review().comments() {
760            comment.id.hash(&mut hasher);
761            comment.anchor.hash(&mut hasher);
762            comment.body.hash(&mut hasher);
763            comment.outdated.hash(&mut hasher);
764        }
765        if let Some(draft) = self.session.draft() {
766            draft.anchor().hash(&mut hasher);
767            draft.body().hash(&mut hasher);
768            draft.cursor().hash(&mut hasher);
769        }
770        hasher.finish()
771    }
772
773    pub(crate) fn select_clicked_row(&mut self, row: u16) {
774        let clicked = self
775            .visible_rows
776            .iter()
777            .find(|(screen_row, _)| *screen_row == row)
778            .map(|(_, index)| *index);
779        if let Some(index) = clicked
780            && self.session.select_row(index)
781        {
782            if self.session.presentation().gap_info(index).is_some() {
783                self.reveal_selected_gap(RevealAmount::Step);
784            }
785            self.request_follow();
786        }
787    }
788}
789
790fn offset(current: usize, delta: isize, last: usize) -> usize {
791    if delta.is_negative() {
792        current.saturating_sub(delta.unsigned_abs())
793    } else {
794        current.saturating_add(delta.unsigned_abs()).min(last)
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801    use clankerdiff_core::FileDiff;
802    use clankerdiff_theme::ThemeId;
803
804    #[test]
805    fn new_uses_the_default_sage_theme() {
806        let state = DiffReviewState::new(Arc::new(DiffDocument::empty()));
807        assert_eq!(state.theme.id(), &ThemeId::Sage);
808    }
809
810    #[test]
811    fn comments_above_selection_count_toward_viewport_height() {
812        let document = Arc::new(DiffDocument {
813            repo_root: "/repo".into(),
814            files: vec![FileDiff::from_texts("a.rs", "a\nb\nc\n", "A\nB\nC\n").unwrap()],
815        });
816        let mut state = DiffReviewState::new(document);
817        state.last_height = 3;
818        let anchor = state.session.selected_anchor().unwrap();
819        state.session.review_mut().add_comment(anchor, "note");
820        state.session.move_row(2);
821        state.follow_selection();
822        assert!(state.scroll > 0);
823    }
824}