quinjet 0.0.73

A fast, live, keyboard-first Git source-control interface for the terminal
#[cfg_attr(not(test), expect(clippy::wildcard_imports, reason = "shared"))]
use super::*;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Scrollbar {
    pub area: Rect,
    pub track: Rect,
    pub thumb: Rect,
    pub max_offset: usize,
    pub viewport: usize,
    pub horizontal: bool,
}

impl Scrollbar {
    pub(crate) fn new(area: Rect, offset: usize, length: usize, viewport: usize) -> Option<Self> {
        if area.width == 0 {
            return None;
        }
        let track = Rect::new(area.right().saturating_sub(1), area.y, 1, area.height);
        Self::build(track, track, offset, length, viewport, false)
    }

    pub(crate) fn horizontal(
        area: Rect,
        offset: usize,
        max_offset: usize,
        viewport: usize,
    ) -> Option<Self> {
        let track = Rect::new(
            area.x.saturating_add(1),
            area.y,
            area.width.saturating_sub(2),
            1,
        );
        Self::build(
            area,
            track,
            offset,
            max_offset.saturating_add(viewport),
            viewport,
            true,
        )
    }

    fn build(
        area: Rect,
        track: Rect,
        offset: usize,
        length: usize,
        viewport: usize,
        horizontal: bool,
    ) -> Option<Self> {
        let extent = if horizontal {
            track.width
        } else {
            track.height
        };
        if track.width == 0
            || track.height == 0
            || extent < 2
            || viewport == 0
            || length <= viewport
        {
            return None;
        }
        let extent = usize::from(extent);
        let thumb_extent = extent
            .saturating_mul(viewport)
            .div_euclid(length)
            .clamp(1, extent - 1);
        let max_offset = length - viewport;
        let start = crate::convert::cells(proportional(
            offset.min(max_offset),
            extent - thumb_extent,
            max_offset,
        ));
        let thumb_extent = crate::convert::cells(thumb_extent);
        let thumb = if horizontal {
            Rect::new(track.x.saturating_add(start), track.y, thumb_extent, 1)
        } else {
            Rect::new(track.x, track.y.saturating_add(start), 1, thumb_extent)
        };
        Some(Self {
            area,
            track,
            thumb,
            max_offset,
            viewport,
            horizontal,
        })
    }

    const fn coordinate(self, column: u16, row: u16) -> u16 {
        if self.horizontal { column } else { row }
    }

    const fn thumb_extent(self) -> u16 {
        if self.horizontal {
            self.thumb.width
        } else {
            self.thumb.height
        }
    }

    fn offset_at(self, position: u16, grab_offset: u16) -> usize {
        let extent = if self.horizontal {
            self.track.width
        } else {
            self.track.height
        };
        let travel = usize::from(extent.saturating_sub(self.thumb_extent()));
        let origin = self.coordinate(self.track.x, self.track.y);
        let start = usize::from(
            position
                .saturating_sub(origin)
                .saturating_sub(grab_offset.min(self.thumb_extent().saturating_sub(1))),
        )
        .min(travel);
        proportional(start, self.max_offset, travel)
    }
}

fn proportional(value: usize, scale: usize, total: usize) -> usize {
    usize::try_from(
        (value as u128 * scale as u128 + (total as u128).div_euclid(2))
            .checked_div(total as u128)
            .unwrap_or_default(),
    )
    .unwrap_or(usize::MAX)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ScrollbarTarget {
    Sidebar,
    Content,
    Horizontal,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ScrollbarDrag {
    target: ScrollbarTarget,
    grab_offset: u16,
    position: u16,
}

impl App {
    pub(super) fn handle_scrollbar_mouse(
        &mut self,
        event: MouseEvent,
        effects: &mut Vec<AppEffect>,
    ) -> bool {
        if self.modal.is_some()
            || self.repository_tab_menu.is_some()
            || self.scm_menu_open
            || self.pr_menu_open
            || !self.mouse_capture
        {
            self.scrollbar_drag = None;
            return false;
        }
        if let Some(mut drag) = self.scrollbar_drag {
            let scrollbar = self.scrollbar(drag.target);
            let Some(scrollbar) = scrollbar else {
                self.scrollbar_drag = None;
                return false;
            };
            match event.kind {
                MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) => {
                    let position = scrollbar.coordinate(event.column, event.row);
                    if position != drag.position {
                        self.apply_scrollbar_offset(
                            drag.target,
                            scrollbar.offset_at(position, drag.grab_offset),
                            scrollbar,
                            effects,
                        );
                        drag.position = position;
                    }
                    self.scrollbar_drag =
                        (event.kind != MouseEventKind::Up(MouseButton::Left)).then_some(drag);
                    return true;
                }
                MouseEventKind::Down(_) => self.scrollbar_drag = None,
                _ => return true,
            }
        }
        if event.kind != MouseEventKind::Down(MouseButton::Left) {
            return false;
        }
        let point = (event.column, event.row).into();
        let hit = [
            (ScrollbarTarget::Sidebar, self.geometry.sidebar_scrollbar),
            (ScrollbarTarget::Content, self.geometry.content_scrollbar),
            (
                ScrollbarTarget::Horizontal,
                self.geometry.horizontal_scrollbar,
            ),
        ]
        .into_iter()
        .find_map(|(target, scrollbar)| {
            scrollbar
                .filter(|scrollbar| scrollbar.area.contains(point))
                .map(|scrollbar| (target, scrollbar))
        });
        let Some((target, scrollbar)) = hit else {
            return false;
        };
        self.text_selection = None;
        self.resize_target = None;
        self.last_resize_tap = None;
        self.set_focus(
            if target == ScrollbarTarget::Sidebar {
                Focus::Sidebar
            } else {
                Focus::Content
            },
            effects,
        );
        if !scrollbar.track.contains(point) {
            self.scroll_horizontal(event.column > scrollbar.track.x);
            return true;
        }
        let position = scrollbar.coordinate(event.column, event.row);
        let in_thumb = scrollbar.thumb.contains(point);
        let grab_offset = if in_thumb {
            position.saturating_sub(scrollbar.coordinate(scrollbar.thumb.x, scrollbar.thumb.y))
        } else {
            scrollbar.thumb_extent().div_euclid(2)
        };
        if !in_thumb {
            self.apply_scrollbar_offset(
                target,
                scrollbar.offset_at(position, grab_offset),
                scrollbar,
                effects,
            );
        }
        self.scrollbar_drag = Some(ScrollbarDrag {
            target,
            grab_offset,
            position,
        });
        true
    }

    const fn scrollbar(&self, target: ScrollbarTarget) -> Option<Scrollbar> {
        match target {
            ScrollbarTarget::Sidebar => self.geometry.sidebar_scrollbar,
            ScrollbarTarget::Content => self.geometry.content_scrollbar,
            ScrollbarTarget::Horizontal => self.geometry.horizontal_scrollbar,
        }
    }

    pub(crate) fn cancel_unavailable_scrollbar_drag(&mut self) {
        if self.modal.is_some()
            || self.repository_tab_menu.is_some()
            || self.scm_menu_open
            || self.pr_menu_open
            || !self.mouse_capture
            || self
                .scrollbar_drag
                .is_some_and(|drag| self.scrollbar(drag.target).is_none())
        {
            self.scrollbar_drag = None;
        }
    }

    fn apply_scrollbar_offset(
        &mut self,
        target: ScrollbarTarget,
        offset: usize,
        scrollbar: Scrollbar,
        effects: &mut Vec<AppEffect>,
    ) {
        match target {
            ScrollbarTarget::Sidebar => {
                self.sidebar_free_scroll = true;
                self.sidebar_offset = offset;
                self.request_history_near_end(offset.saturating_add(scrollbar.viewport), effects);
            }
            ScrollbarTarget::Content => {
                self.content_file_anchor = None;
                self.pull_request_restore_scroll = false;
                self.diff_restore_scroll = false;
                self.content_scroll = offset;
                self.content_at_bottom = offset == scrollbar.max_offset;
            }
            ScrollbarTarget::Horizontal => self.horizontal_scroll = offset,
        }
    }
}