glim-tui 0.1.0

A TUI for monitoring GitLab CI/CD pipelines and projects
use std::sync::mpsc::Sender;
use ratatui::widgets::{ListState, TableState};
use tachyonfx::{fx, Duration, Effect, Interpolation, IntoEffect};
use tachyonfx::fx::{parallel, Direction, Glitch};
use crate::dispatcher::Dispatcher;
use crate::domain::Project;
use crate::event::{GlimEvent, GlitchState};
use crate::glim_app::{GlimApp, GlimConfig, Modulo};
use crate::gruvbox::Gruvbox::{Dark0Hard, Dark3};
use crate::id::PipelineId;
use crate::ui::popup::{ConfigPopupState, PipelineActionsPopupState, ProjectDetailsPopupState};
use crate::ui::widget::NotificationState;

pub struct StatefulWidgets {
    pub last_frame: Duration,
    pub sender: Sender<GlimEvent>,
    pub project_table_state: TableState,
    pub logs_state: ListState,
    pub config_popup_state: Option<ConfigPopupState>,
    pub table_fade_in: Option<Effect>,
    pub project_details: Option<ProjectDetailsPopupState>,
    pub pipeline_actions: Option<PipelineActionsPopupState>,
    pub shader_pipeline: Option<Effect>,
    pub notice: Option<NotificationState>,
    glitch_override: Option<Effect>,
    glitch: Effect,
}

impl StatefulWidgets {
    pub fn new(sender: Sender<GlimEvent>) -> Self {
        Self {
            last_frame: Duration::default(),
            sender,
            project_table_state: TableState::default().with_selected(0),
            logs_state: ListState::default().with_selected(Some(0)),
            table_fade_in: None,
            config_popup_state: None,
            project_details: None,
            pipeline_actions: None,
            shader_pipeline: None,
            glitch_override: None,
            notice: None,
            glitch: Glitch::builder()
                .action_ms(100..500)
                .action_start_delay_ms(0..2000)
                .cell_glitch_ratio(0.0015)
                .build()
                .into_effect()
        }
    }

    pub fn apply(
        &mut self,
        app: &GlimApp,
        event: &GlimEvent
    ) {
        match event {
            GlimEvent::GlitchOverride(g)            => self.glitch_override = make_glitch_effect(*g),

            GlimEvent::SelectNextProject            => self.handle_project_selection(1, app),
            GlimEvent::SelectPreviousProject        => self.handle_project_selection(-1, app),

            GlimEvent::ReceivedProjects(_)          => self.fade_in_projects_table(),

            GlimEvent::OpenProjectDetails(id)       => self.open_project_details(app.project(*id).clone(), app.sender.clone()),
            GlimEvent::CloseProjectDetails          => self.project_details = {
                let fade_in = fx::fade_from(Dark3, Dark0Hard, (300, Interpolation::CircIn));
                self.shader_pipeline = Some(fade_in);

                None
            },
            GlimEvent::ProjectUpdated(p)            => self.refresh_project_details(p),

            GlimEvent::ClosePipelineActions         => self.close_pipeline_actions(),
            GlimEvent::OpenPipelineActions(project_id, pipeline_id) => {
                let project = app.project(*project_id);
                self.open_pipeline_actions(project, *pipeline_id);
            },

            GlimEvent::DisplayConfig                => self.open_config(app.load_config().unwrap_or_default()),
            GlimEvent::CloseConfig                  => self.config_popup_state = None,

            _ => (),
        }
    }

    fn fade_in_projects_table(&mut self) {
        let effect = parallel(&[
            fx::coalesce(550),
            fx::sweep_in(Direction::LeftToRight, 50, 0, Dark0Hard, (450, Interpolation::QuadIn))
        ]);
        self.table_fade_in = Some(effect);
    }

    fn refresh_project_details(&mut self, project: &Project) {
        let requires_refresh = self.project_details.as_ref()
            .map_or(false, |pd| pd.project.id == project.id);

        if requires_refresh {
            let existing = self.project_details.take().unwrap();
            self.project_details = Some(existing.with_project(project.clone()));
        }
    }

    fn open_project_details(&mut self, project: Project, sender: Sender<GlimEvent>) {
        project.recent_pipelines().first()
            .map(|p| sender.dispatch(GlimEvent::SelectedPipeline(p.id)))
            .unwrap_or(());

        self.project_details = Some(ProjectDetailsPopupState::new(project));
    }

    fn open_config(&mut self, config: GlimConfig) {
        self.config_popup_state = Some(ConfigPopupState::new(config));
    }

    fn open_pipeline_actions(
        &mut self,
        project: &Project,
        pipeline_id: PipelineId
    ) {
        let failed_job = project
            .pipeline(pipeline_id)
            .and_then(|p| p.failed_job());

        let actions = if let Some(job) = failed_job {
            vec![
                GlimEvent::BrowseToJob(project.id, pipeline_id, job.id),
                GlimEvent::BrowseToPipeline(project.id, pipeline_id),
                GlimEvent::BrowseToProject(project.id),
                GlimEvent::DownloadErrorLog(project.id, pipeline_id),
            ]
        } else {
            vec![
                GlimEvent::BrowseToPipeline(project.id, pipeline_id),
                GlimEvent::BrowseToProject(project.id),
            ]
        };

        self.pipeline_actions = Some(PipelineActionsPopupState::new(actions, project.id, pipeline_id));
    }

    fn close_pipeline_actions(&mut self) {
        self.pipeline_actions = None;
    }

    fn handle_project_selection(&mut self, direction: i32, app: &GlimApp) {
        let projects = app.projects();
        if projects.is_empty() { return; }

        if let Some(current) = self.project_table_state.selected() {
            let new_index = match direction {
                1  => current.saturating_add(1),
                -1 => current.saturating_sub(1),
                n  => panic!("invalid direction: {n}")
            }.min(projects.len().saturating_sub(1));

            self.project_table_state.select(Some(new_index));
            let project = &projects[new_index];
            app.dispatch(GlimEvent::SelectedProject(project.id));
        } else {
            self.project_table_state.select(Some(0));
        }
    }

    pub fn handle_pipeline_selection(&mut self, direction: i32) {
        if self.project_details.is_none() { return; }
        let pd = self.project_details.as_mut().unwrap();

        if let Some(current) = pd.pipelines_table_state.selected() {
            let pipelines = pd.project.recent_pipelines();

            let new_index = (current as i32 + direction)
                .modulo(pipelines.len() as i32) as usize;

            if pipelines.is_empty() {
                pd.pipelines_table_state.select(None);
            } else {
                pd.pipelines_table_state.select(Some(new_index));
                let pipeline = &pipelines[new_index];
                self.sender.dispatch(GlimEvent::SelectedPipeline(pipeline.id));
            }
        }
    }

    pub fn handle_pipeline_action_selection(&mut self, direction: i32) {
        if self.pipeline_actions.is_none() { return; }

        let pipelines = self.pipeline_actions.as_mut().unwrap();
        if let Some(current) = pipelines.list_state.selected() {
            let new_index = (current as i32 + direction)
                .modulo(pipelines.actions.len() as i32);

            pipelines.list_state.select(Some(new_index as usize));
        }
    }

    pub fn glitch(&mut self) -> &mut Effect {
        match self.glitch_override.as_mut() {
            Some(g) => g,
            None => &mut self.glitch
        }
    }
}


fn make_glitch_effect(glitch_state: GlitchState) -> Option<Effect> {
    match glitch_state {
        GlitchState::Inactive => None,
        GlitchState::Active => Some(Glitch::builder()
            .action_ms(100..200)
            .action_start_delay_ms(0..500)
            .cell_glitch_ratio(0.05)
            .build()
            .into_effect())
    }
}