sabiql 1.9.1

A fast, driver-less TUI for browsing and editing PostgreSQL databases
Documentation
use crate::app::cmd::effect::Effect;
use crate::app::model::app_state::AppState;
use crate::app::model::shared::inspector_tab::InspectorTab;
use crate::app::model::shared::viewport::{
    calculate_next_column_offset, calculate_prev_column_offset,
};
use crate::app::services::AppServices;
use crate::app::update::action::{Action, ScrollAmount, ScrollDirection, ScrollTarget};

use super::inspector_max_scroll;

pub fn reduce(
    state: &mut AppState,
    action: &Action,
    services: &AppServices,
) -> Option<Vec<Effect>> {
    match action {
        Action::Scroll {
            target: ScrollTarget::Inspector,
            direction: ScrollDirection::Up,
            amount: ScrollAmount::Line,
        } => {
            state.ui.inspector_scroll_offset = state.ui.inspector_scroll_offset.saturating_sub(1);
            Some(vec![])
        }
        Action::Scroll {
            target: ScrollTarget::Inspector,
            direction: ScrollDirection::Down,
            amount: ScrollAmount::Line,
        } => {
            let max_offset = inspector_max_scroll(state, services);
            if state.ui.inspector_scroll_offset < max_offset {
                state.ui.inspector_scroll_offset += 1;
            }
            Some(vec![])
        }
        Action::Scroll {
            target: ScrollTarget::Inspector,
            direction: ScrollDirection::Up,
            amount: ScrollAmount::ToStart,
        } => {
            state.ui.inspector_scroll_offset = 0;
            Some(vec![])
        }
        Action::Scroll {
            target: ScrollTarget::Inspector,
            direction: ScrollDirection::Down,
            amount: ScrollAmount::ToEnd,
        } => {
            state.ui.inspector_scroll_offset = inspector_max_scroll(state, services);
            Some(vec![])
        }
        Action::Scroll {
            target: ScrollTarget::Inspector,
            direction: ScrollDirection::Down,
            amount: ScrollAmount::HalfPage,
        } => {
            let visible = match state.ui.inspector_tab {
                InspectorTab::Ddl => state.inspector_ddl_visible_rows(),
                _ => state.inspector_visible_rows(),
            };
            let delta = (visible / 2).max(1);
            let max = inspector_max_scroll(state, services);
            state.ui.inspector_scroll_offset = (state.ui.inspector_scroll_offset + delta).min(max);
            Some(vec![])
        }
        Action::Scroll {
            target: ScrollTarget::Inspector,
            direction: ScrollDirection::Up,
            amount: ScrollAmount::HalfPage,
        } => {
            let visible = match state.ui.inspector_tab {
                InspectorTab::Ddl => state.inspector_ddl_visible_rows(),
                _ => state.inspector_visible_rows(),
            };
            let delta = (visible / 2).max(1);
            state.ui.inspector_scroll_offset =
                state.ui.inspector_scroll_offset.saturating_sub(delta);
            Some(vec![])
        }
        Action::Scroll {
            target: ScrollTarget::Inspector,
            direction: ScrollDirection::Down,
            amount: ScrollAmount::FullPage,
        } => {
            let visible = match state.ui.inspector_tab {
                InspectorTab::Ddl => state.inspector_ddl_visible_rows(),
                _ => state.inspector_visible_rows(),
            };
            let delta = visible.max(1);
            let max = inspector_max_scroll(state, services);
            state.ui.inspector_scroll_offset = (state.ui.inspector_scroll_offset + delta).min(max);
            Some(vec![])
        }
        Action::Scroll {
            target: ScrollTarget::Inspector,
            direction: ScrollDirection::Up,
            amount: ScrollAmount::FullPage,
        } => {
            let visible = match state.ui.inspector_tab {
                InspectorTab::Ddl => state.inspector_ddl_visible_rows(),
                _ => state.inspector_visible_rows(),
            };
            let delta = visible.max(1);
            state.ui.inspector_scroll_offset =
                state.ui.inspector_scroll_offset.saturating_sub(delta);
            Some(vec![])
        }
        Action::Scroll {
            target: ScrollTarget::Inspector,
            direction: ScrollDirection::Left,
            amount: ScrollAmount::Line,
        } => {
            state.ui.inspector_horizontal_offset =
                calculate_prev_column_offset(state.ui.inspector_horizontal_offset);
            Some(vec![])
        }
        Action::Scroll {
            target: ScrollTarget::Inspector,
            direction: ScrollDirection::Right,
            amount: ScrollAmount::Line,
        } => {
            let plan = &state.ui.inspector_viewport_plan;
            let all_widths_len = plan.max_offset + plan.column_count;
            state.ui.inspector_horizontal_offset = calculate_next_column_offset(
                all_widths_len,
                state.ui.inspector_horizontal_offset,
                plan.column_count,
            );
            Some(vec![])
        }

        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::update::browse::navigation::reduce_navigation;
    use crate::domain::{Column, Table};
    use std::time::Instant;

    mod inspector_scroll_top_bottom {
        use super::*;

        fn state_with_table_detail(columns: usize) -> AppState {
            let mut state = AppState::new("test".to_string());
            state.ui.inspector_pane_height = 10;
            state.ui.inspector_tab = InspectorTab::Columns;
            let cols: Vec<Column> = (0..columns)
                .map(|i| Column {
                    name: format!("col_{i}"),
                    data_type: "text".to_string(),
                    nullable: false,
                    default: None,
                    is_primary_key: false,
                    is_unique: false,
                    comment: None,
                    ordinal_position: i as i32,
                })
                .collect();
            state.session.set_table_detail_raw(Some(Table {
                schema: "public".to_string(),
                name: "test_table".to_string(),
                owner: None,
                columns: cols,
                primary_key: None,
                indexes: vec![],
                foreign_keys: vec![],
                rls: None,
                triggers: vec![],
                row_count_estimate: Some(0),
                comment: None,
            }));
            state
        }

        #[test]
        fn inspector_scroll_top_resets_to_zero() {
            let mut state = state_with_table_detail(20);
            state.ui.inspector_scroll_offset = 10;

            let effects = reduce_navigation(
                &mut state,
                &Action::Scroll {
                    target: ScrollTarget::Inspector,
                    direction: ScrollDirection::Up,
                    amount: ScrollAmount::ToStart,
                },
                &AppServices::stub(),
                Instant::now(),
            );

            assert!(effects.is_some());
            assert_eq!(state.ui.inspector_scroll_offset, 0);
        }

        #[test]
        fn inspector_scroll_bottom_goes_to_max() {
            let mut state = state_with_table_detail(20);
            state.ui.inspector_scroll_offset = 0;
            let visible = state.inspector_visible_rows();
            let expected_max = 20_usize.saturating_sub(visible);

            let effects = reduce_navigation(
                &mut state,
                &Action::Scroll {
                    target: ScrollTarget::Inspector,
                    direction: ScrollDirection::Down,
                    amount: ScrollAmount::ToEnd,
                },
                &AppServices::stub(),
                Instant::now(),
            );

            assert!(effects.is_some());
            assert_eq!(state.ui.inspector_scroll_offset, expected_max);
        }

        #[test]
        fn inspector_scroll_bottom_no_detail_stays_zero() {
            let mut state = AppState::new("test".to_string());
            state.ui.inspector_pane_height = 10;

            let effects = reduce_navigation(
                &mut state,
                &Action::Scroll {
                    target: ScrollTarget::Inspector,
                    direction: ScrollDirection::Down,
                    amount: ScrollAmount::ToEnd,
                },
                &AppServices::stub(),
                Instant::now(),
            );

            assert!(effects.is_some());
            assert_eq!(state.ui.inspector_scroll_offset, 0);
        }
    }
}