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