rusticity-term 0.1.10

Terminal UI components for Rusticity
use crate::app::App;
use crate::common::{
    filter_by_fields, render_pagination_text, ColumnId, InputFocus, SortDirection,
};
use crate::keymap::Mode;
use crate::table::{TableState, TableWindow};
use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
use crate::ui::table::{expanded_from_columns, render_table, TableConfig};
use crate::ui::{format_title, titled_block};
use crate::vpc::columns::Column;
use ratatui::{prelude::*, widgets::Paragraph};
use rusticity_core::vpc::Vpc;

pub struct State {
    pub vpcs: TableState<Vpc>,
    pub input_focus: InputFocus,
    pub column_ids: Vec<ColumnId>,
    pub visible_column_ids: Vec<ColumnId>,
}
impl Default for State {
    fn default() -> Self {
        Self {
            vpcs: TableState::new(),
            input_focus: InputFocus::Filter,
            column_ids: Column::ids(),
            visible_column_ids: Column::default_visible_ids(),
        }
    }
}

pub fn filtered_vpcs(app: &App) -> Vec<&Vpc> {
    let mut vpcs = filter_by_fields(
        &app.vpc_state.vpcs.items,
        &app.vpc_state.vpcs.filter,
        |vpc| {
            [
                &vpc.name,
                &vpc.vpc_id,
                &vpc.state,
                &vpc.ipv4_cidrs,
                &vpc.ipv6_cidrs,
                &vpc.owner_id,
                &vpc.region,
                &vpc.arn,
                &vpc.dhcp_options_id,
                &vpc.tenancy,
                &vpc.encryption_control_id,
                &vpc.encryption_control_mode,
                &vpc.block_public_access,
                &vpc.main_route_table,
                &vpc.main_network_acl,
            ]
        },
    );
    vpcs.sort_by(|a, b| a.vpc_id.cmp(&b.vpc_id));
    vpcs
}

pub fn render_vpcs(frame: &mut Frame, app: &App, area: Rect) {
    let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area);
    let state = &app.vpc_state;
    let view = state
        .vpcs
        .prepare_view(filtered_vpcs(app), TableWindow::SelectedPage);
    let pagination = render_pagination_text(view.current_page, view.total_pages);
    render_simple_filter(
        frame,
        chunks[0],
        SimpleFilterConfig {
            filter_text: &state.vpcs.filter,
            placeholder: "Search by name, VPC ID, CIDR, or owner",
            pagination: &pagination,
            mode: app.mode,
            is_input_focused: state.input_focus == InputFocus::Filter,
            is_pagination_focused: state.input_focus == InputFocus::Pagination,
        },
    );
    if state.vpcs.loading && state.vpcs.items.is_empty() {
        frame.render_widget(
            Paragraph::new("Loading VPCs...")
                .block(titled_block("VPCs"))
                .alignment(Alignment::Center),
            chunks[1],
        );
        return;
    }
    let columns: Vec<_> = state
        .visible_column_ids
        .iter()
        .filter_map(|id| Column::from_id(id))
        .map(Column::into_table_column)
        .collect();
    let expanded_columns: Vec<_> = Column::ALL
        .into_iter()
        .map(Column::into_table_column)
        .collect();
    render_table(
        frame,
        TableConfig {
            items: view.items,
            selected_index: view.selected_index,
            expanded_index: view.expanded_index,
            columns: &columns,
            sort_column: &Column::VpcId.name(),
            sort_direction: SortDirection::Asc,
            title: format_title(&format!("VPCs ({})", view.total_items)),
            area: chunks[1],
            get_expanded_content: Some(Box::new(|vpc| {
                expanded_from_columns(&expanded_columns, vpc)
            })),
            is_active: app.mode == Mode::Normal,
        },
    );
}

#[cfg(test)]
mod filter_tests {
    use super::*;

    fn vpc(name: &str, vpc_id: &str, state: &str) -> Vpc {
        Vpc {
            name: name.to_string(),
            vpc_id: vpc_id.to_string(),
            state: state.to_string(),
            ..Default::default()
        }
    }

    #[test]
    fn searches_many_fields_case_insensitively_and_keeps_vpc_id_sort() {
        let mut app = App::new_without_client("default".to_string(), None);
        app.vpc_state.vpcs.items = vec![
            vpc("prod", "vpc-zzz", "available"),
            vpc("dev", "vpc-aaa", "available"),
        ];

        // Name match (case-insensitive).
        app.vpc_state.vpcs.filter = "PROD".to_string();
        let f = filtered_vpcs(&app);
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].vpc_id, "vpc-zzz");

        // Shared state match → both, sorted by vpc_id ascending.
        app.vpc_state.vpcs.filter = "available".to_string();
        let f = filtered_vpcs(&app);
        assert_eq!(
            f.iter().map(|v| v.vpc_id.as_str()).collect::<Vec<_>>(),
            ["vpc-aaa", "vpc-zzz"]
        );
    }

    #[test]
    fn empty_filter_returns_all_and_whitespace_is_significant() {
        let mut app = App::new_without_client("default".to_string(), None);
        app.vpc_state.vpcs.items = vec![vpc("prod", "vpc-1", "available")];

        app.vpc_state.vpcs.filter = String::new();
        assert_eq!(filtered_vpcs(&app).len(), 1);

        // No trimming.
        app.vpc_state.vpcs.filter = " vpc-1 ".to_string();
        assert!(filtered_vpcs(&app).is_empty());
    }
}