Codex Handbook
tui/src/bottom_pane/scroll_state.rs 204 lines
/// Generic scroll/selection state for a vertical list menu.////// Encapsulates the common behavior of a selectable list that supports:/// - Optional selection (None when list is empty)/// - Wrap-around navigation on Up/Down/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible////// Callers own the filtered row count and the visible window size. Every/// mutation method takes those values instead of caching them here, so list/// views can apply filters, pagination, or density changes without this helper/// knowing about their data model. Passing a stale length after filtering would/// leave selection pointing at the wrong row, so callers should clamp or move/// through this type immediately after changing their visible row set.#[derive(Debug, Default, Clone, Copy)]pub(crate) struct ScrollState {    pub selected_idx: Option<usize>,    pub scroll_top: usize,}impl ScrollState {    pub fn new() -> Self {        Self {            selected_idx: None,            scroll_top: 0,        }    }    /// Reset selection and scroll.    pub fn reset(&mut self) {        self.selected_idx = None;        self.scroll_top = 0;    }    /// Clamp selection to be within the [0, len-1] range, or None when empty.    pub fn clamp_selection(&mut self, len: usize) {        if self.clear_if_empty(len) {            return;        }        self.selected_idx = Some(self.selected_idx.unwrap_or(0).min(len - 1));    }    /// Move selection up by one, wrapping to the bottom when necessary.    pub fn move_up_wrap(&mut self, len: usize) {        if self.clear_if_empty(len) {            return;        }        self.selected_idx = Some(match self.selected_idx {            Some(idx) if idx > 0 => idx - 1,            Some(_) => len - 1,            None => 0,        });    }    /// Move selection down by one, wrapping to the top when necessary.    pub fn move_down_wrap(&mut self, len: usize) {        if self.clear_if_empty(len) {            return;        }        self.selected_idx = Some(match self.selected_idx {            Some(idx) if idx + 1 < len => idx + 1,            _ => 0,        });    }    /// Move selection up by one visible page, clamping at the first row.    ///    /// Page movement intentionally does not wrap. It mirrors terminal list    /// behavior where repeated page-up/page-down converges at the nearest edge    /// while still keeping the selected row visible.    pub fn page_up_clamped(&mut self, len: usize, visible_rows: usize) {        if self.clear_if_empty(len) {            return;        }        let step = visible_rows.max(1);        let current = self.selected_idx.unwrap_or(0).min(len - 1);        self.selected_idx = Some(current.saturating_sub(step));        self.ensure_visible(len, visible_rows);    }    /// Move selection down by one visible page, clamping at the last row.    ///    /// Page movement intentionally does not wrap. It mirrors terminal list    /// behavior where repeated page-up/page-down converges at the nearest edge    /// while still keeping the selected row visible.    pub fn page_down_clamped(&mut self, len: usize, visible_rows: usize) {        if self.clear_if_empty(len) {            return;        }        let step = visible_rows.max(1);        let current = self.selected_idx.unwrap_or(0).min(len - 1);        self.selected_idx = Some(current.saturating_add(step).min(len - 1));        self.ensure_visible(len, visible_rows);    }    /// Jump selection to the first row.    pub fn jump_top(&mut self, len: usize, visible_rows: usize) {        if self.clear_if_empty(len) {            return;        }        self.selected_idx = Some(0);        self.ensure_visible(len, visible_rows);    }    /// Jump selection to the last row.    pub fn jump_bottom(&mut self, len: usize, visible_rows: usize) {        if self.clear_if_empty(len) {            return;        }        self.selected_idx = Some(len - 1);        self.ensure_visible(len, visible_rows);    }    fn clear_if_empty(&mut self, len: usize) -> bool {        if len != 0 {            return false;        }        self.selected_idx = None;        self.scroll_top = 0;        true    }    /// Adjust `scroll_top` so that the current `selected_idx` is visible within    /// the window of `visible_rows`.    pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) {        if len == 0 || visible_rows == 0 {            self.scroll_top = 0;            return;        }        if let Some(sel) = self.selected_idx {            if sel < self.scroll_top {                self.scroll_top = sel;            } else {                let bottom = self.scroll_top + visible_rows - 1;                if sel > bottom {                    self.scroll_top = sel + 1 - visible_rows;                }            }        } else {            self.scroll_top = 0;        }    }}#[cfg(test)]mod tests {    use super::ScrollState;    #[test]    fn wrap_navigation_and_visibility() {        let mut s = ScrollState::new();        let len = 10;        let vis = 5;        s.clamp_selection(len);        assert_eq!(s.selected_idx, Some(0));        s.ensure_visible(len, vis);        assert_eq!(s.scroll_top, 0);        s.move_up_wrap(len);        s.ensure_visible(len, vis);        assert_eq!(s.selected_idx, Some(len - 1));        match s.selected_idx {            Some(sel) => assert!(s.scroll_top <= sel),            None => panic!("expected Some(selected_idx) after wrap"),        }        s.move_down_wrap(len);        s.ensure_visible(len, vis);        assert_eq!(s.selected_idx, Some(0));        assert_eq!(s.scroll_top, 0);    }    #[test]    fn page_and_jump_navigation_clamps() {        let mut s = ScrollState::new();        let len = 10;        let vis = 4;        s.clamp_selection(len);        s.page_down_clamped(len, vis);        assert_eq!(s.selected_idx, Some(4));        assert_eq!(s.scroll_top, 1);        s.page_down_clamped(len, vis);        assert_eq!(s.selected_idx, Some(8));        assert_eq!(s.scroll_top, 5);        s.page_down_clamped(len, vis);        assert_eq!(s.selected_idx, Some(9));        assert_eq!(s.scroll_top, 6);        s.page_up_clamped(len, vis);        assert_eq!(s.selected_idx, Some(5));        assert_eq!(s.scroll_top, 5);        s.jump_top(len, vis);        assert_eq!(s.selected_idx, Some(0));        assert_eq!(s.scroll_top, 0);        s.jump_bottom(len, vis);        assert_eq!(s.selected_idx, Some(9));        assert_eq!(s.scroll_top, 6);    }}