foundry-tui-app 0.0.5

Application model and controller for foundry-tui
Documentation
use chrono::Local;
use crossterm::event::KeyEvent;
use foundry_tui_config::ActionId;
use foundry_tui_foundry::{ToolEvent, ToolKind};
use tokio::sync::mpsc::UnboundedSender;

use crate::{
    model::{LogLine, LogStream, LogTextMode, SectionFocus, Tab},
    parsing::{is_non_evm_preset, key_matches, rpc_chain_label},
};

use super::AppController;

impl AppController {
    pub(crate) fn resolve_action(&self, key: KeyEvent) -> Option<ActionId> {
        self.config
            .keys
            .bindings
            .iter()
            .find_map(|(action, binding)| key_matches(binding, key).then_some(*action))
    }

    pub(crate) fn execute_action(
        &mut self,
        action: ActionId,
        tool_events: &UnboundedSender<ToolEvent>,
    ) {
        match action {
            ActionId::Quit => self.model.should_quit = true,
            ActionId::NextTab => self.step_tab(1),
            ActionId::PrevTab => self.step_tab(-1),
            ActionId::FocusNextSection => self.cycle_section_focus(true),
            ActionId::FocusPrevSection => self.cycle_section_focus(false),
            ActionId::OpenPalette => {
                self.model.palette_open = !self.model.palette_open;
                self.model.palette_index = 0;
            }
            ActionId::ToggleBuildOnboarding => {
                self.model.show_build_onboarding = true;
                self.model.notification = Some("build onboarding is always visible".to_string());
            }
            ActionId::ToggleLogWrapMode => {
                self.model.log_text_mode = self.model.log_text_mode.toggle();
                self.model.notification = Some(format!(
                    "log text mode: {}",
                    self.model.log_text_mode.label()
                ));
            }
            ActionId::OpenThemePicker | ActionId::ThemeNext | ActionId::ThemePrev => {
                self.model.notification = Some(
                    "theme switching was removed; using fixed bold-contrast theme".to_string(),
                );
            }
            ActionId::RunCustomCommand => self.open_custom_runner(),
            ActionId::RunBuild => {
                let _ = self.start_tool_job(
                    "Forge Build",
                    ToolKind::Forge,
                    self.config.foundry.workflows.build.clone(),
                    tool_events,
                );
            }
            ActionId::RunTest => {
                let _ = self.start_tool_job(
                    "Forge Test",
                    ToolKind::Forge,
                    self.config.foundry.workflows.test.clone(),
                    tool_events,
                );
            }
            ActionId::RunScript => {
                let _ = self.start_tool_job(
                    "Forge Script",
                    ToolKind::Forge,
                    self.config.foundry.workflows.script.clone(),
                    tool_events,
                );
            }
            ActionId::RunCastBlockNumber => {
                self.run_cast_block_number(tool_events);
            }
            ActionId::RunVerifyCheck => {
                let _ = self.start_tool_job(
                    "Forge Verify Check",
                    ToolKind::Forge,
                    self.config.foundry.workflows.verify_check.clone(),
                    tool_events,
                );
            }
            ActionId::RunChiselList => {
                let _ = self.start_tool_job(
                    "Chisel List",
                    ToolKind::Chisel,
                    self.config.foundry.workflows.chisel_list.clone(),
                    tool_events,
                );
            }
            ActionId::RunFoundryupUpdate => {
                let _ = self.start_tool_job(
                    "Foundryup Update",
                    ToolKind::Foundryup,
                    self.config.foundry.workflows.foundryup_update.clone(),
                    tool_events,
                );
            }
            ActionId::StartAnvil => self.start_anvil(),
            ActionId::StopAnvil => self.stop_anvil(),
            ActionId::ScrollLogsUp => self.scroll_focused_section(false),
            ActionId::ScrollLogsDown => self.scroll_focused_section(true),
        }
    }

    pub(crate) fn scroll_focused_section_horizontal(&mut self, right: bool) -> bool {
        if self.model.log_text_mode != LogTextMode::Horizontal {
            return false;
        }

        const HSCROLL_STEP: usize = 8;

        match self.model.focused_section {
            SectionFocus::LogsPanel => {
                let max_offset = self
                    .model
                    .logs
                    .iter()
                    .map(|entry| entry.message.chars().count())
                    .max()
                    .unwrap_or(0);
                if right {
                    self.model.logs_hscroll = self
                        .model
                        .logs_hscroll
                        .saturating_add(HSCROLL_STEP)
                        .min(max_offset);
                } else {
                    self.model.logs_hscroll = self.model.logs_hscroll.saturating_sub(HSCROLL_STEP);
                }
                true
            }
            SectionFocus::AnvilInstanceLogsPanel => {
                let max_offset = self
                    .model
                    .anvil_instances
                    .get(self.model.selected_anvil_index)
                    .map(|instance| {
                        instance
                            .logs
                            .iter()
                            .map(|entry| entry.message.chars().count())
                            .max()
                            .unwrap_or(0)
                    })
                    .unwrap_or(0);
                if right {
                    self.model.anvil_logs_hscroll = self
                        .model
                        .anvil_logs_hscroll
                        .saturating_add(HSCROLL_STEP)
                        .min(max_offset);
                } else {
                    self.model.anvil_logs_hscroll =
                        self.model.anvil_logs_hscroll.saturating_sub(HSCROLL_STEP);
                }
                true
            }
            SectionFocus::MainPanel if self.model.active_tab == Tab::Logs => {
                let max_offset = self
                    .model
                    .logs
                    .iter()
                    .map(|entry| entry.message.chars().count())
                    .max()
                    .unwrap_or(0);
                if right {
                    self.model.logs_hscroll = self
                        .model
                        .logs_hscroll
                        .saturating_add(HSCROLL_STEP)
                        .min(max_offset);
                } else {
                    self.model.logs_hscroll = self.model.logs_hscroll.saturating_sub(HSCROLL_STEP);
                }
                true
            }
            _ => false,
        }
    }

    fn step_tab(&mut self, offset: isize) {
        let len = self.model.tabs.len() as isize;
        let current = self.model.active_tab_index() as isize;
        let next = (current + offset).rem_euclid(len) as usize;
        self.model.active_tab = self.model.tabs[next];
        self.normalize_focus_for_tab();
    }

    fn cycle_section_focus(&mut self, forward: bool) {
        let sections = self.available_sections();
        if sections.is_empty() {
            return;
        }

        let current_index = sections
            .iter()
            .position(|section| *section == self.model.focused_section)
            .unwrap_or(0);

        let next_index = if forward {
            (current_index + 1) % sections.len()
        } else if current_index == 0 {
            sections.len() - 1
        } else {
            current_index - 1
        };

        self.model.focused_section = sections[next_index];
        self.model.notification = Some(format!(
            "focused section: {}",
            self.model.focused_section.label()
        ));
    }

    pub(crate) fn scroll_focused_section(&mut self, down: bool) {
        match self.model.focused_section {
            SectionFocus::MainPanel => {
                if self.model.active_tab == Tab::Custom {
                    if down {
                        self.select_next_custom_template();
                    } else {
                        self.select_prev_custom_template();
                    }
                    return;
                }
                if down {
                    self.model.main_scroll = self.model.main_scroll.saturating_add(1);
                } else {
                    self.model.main_scroll = self.model.main_scroll.saturating_sub(1);
                }
            }
            SectionFocus::JobsPanel => {
                if down {
                    self.model.jobs_scroll = self.model.jobs_scroll.saturating_add(1);
                } else {
                    self.model.jobs_scroll = self.model.jobs_scroll.saturating_sub(1);
                }
            }
            SectionFocus::LogsPanel => {
                if down {
                    self.model.logs_scroll = self.model.logs_scroll.saturating_sub(1);
                } else {
                    self.model.logs_scroll = self.model.logs_scroll.saturating_add(1);
                }
            }
            SectionFocus::AnvilInstancesPanel => {
                if down {
                    self.select_next_anvil_instance();
                } else {
                    self.select_prev_anvil_instance();
                }
            }
            SectionFocus::AnvilInstanceLogsPanel => {
                if down {
                    self.model.anvil_logs_scroll = self.model.anvil_logs_scroll.saturating_sub(1);
                } else {
                    self.model.anvil_logs_scroll = self.model.anvil_logs_scroll.saturating_add(1);
                }
            }
        }
    }

    pub(crate) fn available_sections(&self) -> Vec<SectionFocus> {
        match self.model.active_tab {
            Tab::Anvil => vec![
                SectionFocus::AnvilInstancesPanel,
                SectionFocus::AnvilInstanceLogsPanel,
                SectionFocus::JobsPanel,
                SectionFocus::LogsPanel,
            ],
            _ => vec![
                SectionFocus::MainPanel,
                SectionFocus::JobsPanel,
                SectionFocus::LogsPanel,
            ],
        }
    }

    pub(crate) fn normalize_focus_for_tab(&mut self) {
        let sections = self.available_sections();
        if !sections.contains(&self.model.focused_section) {
            self.model.focused_section = sections[0];
        }
    }

    fn run_cast_block_number(&mut self, tool_events: &UnboundedSender<ToolEvent>) {
        let Some((preset, url)) = self.active_rpc_target() else {
            self.model.notification = Some("no RPC preset configured for cast".to_string());
            return;
        };

        let chain_label = rpc_chain_label(&preset);
        self.model.active_rpc_preset = Some(preset.clone());
        self.model.active_rpc_chain = Some(chain_label.clone());
        self.model.active_rpc_url = Some(url.clone());

        if is_non_evm_preset(&preset) {
            self.model.notification = Some(format!(
                "preset `{}` is non-EVM ({}). select an EVM preset for cast",
                preset, chain_label
            ));
            return;
        }

        let entry = LogLine {
            ts: Local::now(),
            job_id: None,
            stream: LogStream::System,
            message: format!(
                "cast block-number target => chain={} preset={} rpc={}",
                chain_label, preset, url
            ),
        };
        self.push_log(entry);

        self.model.notification = Some(format!("querying {} via `{}`", chain_label, preset));

        let label = format!("Cast Block Number ({chain_label})");
        let _ = self.start_tool_job(
            &label,
            ToolKind::Cast,
            self.config.foundry.workflows.cast_block_number.clone(),
            tool_events,
        );
    }
}