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