Skip to main content

clankerdiff_ratatui/
state.rs

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