From b009fecc9c5e88cd12ecab3c2c4c68c92c66b7e0 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Tue, 2 Jun 2026 00:11:22 +0530 Subject: [PATCH 1/2] feat: add dual-focus navigation with interactive action execution - Add FocusPane enum (Sidebar/Main) and selected_action index to UiState - Tab/BackTab toggles focus between sidebar and main panel - Left/Right arrows switch focus for discoverability - Up/Down navigates action list when main panel is focused - Enter executes selected action when main panel is focused - All 8 screens now render selectable action lists via render_action_list() - Visual focus indicators: accent borders, dot marker on focused pane title - Selected actions highlighted with triangle marker and bold accent style - Disabled actions shown with muted style and '(unavailable)' suffix - Action selection resets when switching screens via sidebar Fixes: Dashboard and screen actions were previously static text with no way to select or execute them from the UI. Users had to use Ctrl+P command palette or keyboard shortcuts as the only way to run actions. --- src/ui/dashboard.rs | 324 +++++++++++++++++++++++++++++++++++--------- src/ui/state.rs | 55 +++++++- 2 files changed, 317 insertions(+), 62 deletions(-) diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index 7ac1c49..6095748 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -21,7 +21,7 @@ use crate::{ services::executor::{CommandExecutor, KdcExecutor}, ui::{ command_palette, folder_picker, - state::{FirstLaunchChoice, Notification, NotificationLevel, UiPhase}, + state::{FirstLaunchChoice, FocusPane, Notification, NotificationLevel, UiPhase}, statusbar, theme::{self, ThemeName}, }, @@ -87,17 +87,68 @@ fn run_loop(terminal: &mut DefaultTerminal, state: &mut AppState) -> io::Result< router::route_to(state, Screen::Monitoring) } (KeyCode::Char('t'), _) => cycle_theme(state), + (KeyCode::Tab, _) | (KeyCode::BackTab, _) => { + state.ui.toggle_focus(); + } + (KeyCode::Left, _) => { + state.ui.focus = FocusPane::Sidebar; + } + (KeyCode::Right, _) => { + state.ui.focus = FocusPane::Main; + } (KeyCode::Down, _) | (KeyCode::Char('j'), _) => { state.ui.clear_execution_output(); - navigation::move_next(state); + match state.ui.focus { + FocusPane::Sidebar => navigation::move_next(state), + FocusPane::Main => { + let total = state + .ui + .screen_actions(&state.actions, state.current_screen) + .len(); + state.ui.move_action_next(total); + } + } } (KeyCode::Up, _) | (KeyCode::Char('k'), _) => { state.ui.clear_execution_output(); - navigation::move_previous(state); + match state.ui.focus { + FocusPane::Sidebar => navigation::move_previous(state), + FocusPane::Main => { + let total = state + .ui + .screen_actions(&state.actions, state.current_screen) + .len(); + state.ui.move_action_previous(total); + } + } } (KeyCode::Enter, _) => { state.ui.clear_execution_output(); - router::select_current_menu(state); + match state.ui.focus { + FocusPane::Sidebar => { + state.ui.reset_action_selection(); + router::select_current_menu(state); + } + FocusPane::Main => { + let screen_actions: Vec<_> = state + .ui + .screen_actions(&state.actions, state.current_screen) + .iter() + .map(|a| (a.id.clone(), a.screen, a.label.clone(), a.enabled, a.reason.clone())) + .collect(); + if let Some((id, screen, label, enabled, reason)) = + screen_actions.get(state.ui.selected_action).cloned() + { + if enabled { + execute_action(state, &id, screen, &label); + } else { + state.ui.push_notification(Notification::warning( + reason.unwrap_or_else(|| "Action unavailable".to_string()), + )); + } + } + } + } } _ => {} } @@ -212,6 +263,7 @@ fn render_header(frame: &mut Frame, area: Rect, state: &AppState, palette: theme } fn render_sidebar(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { + let is_focused = state.ui.focus == FocusPane::Sidebar; let items = state .menus .iter() @@ -236,8 +288,24 @@ fn render_sidebar(frame: &mut Frame, area: Rect, state: &AppState, palette: them }) .collect::>(); + let border_style = if is_focused { + Style::default().fg(palette.accent) + } else { + Style::default().fg(palette.muted) + }; + let title = if is_focused { + " Navigation ● " + } else { + " Navigation " + }; + frame.render_widget( - List::new(items).block(Block::default().title(" Navigation ").borders(Borders::ALL)), + List::new(items).block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style), + ), area, ); } @@ -306,116 +374,152 @@ fn render_dashboard(frame: &mut Frame, area: Rect, state: &AppState, palette: th ); } - let actions = state - .actions - .iter() - .take(10) - .map(|action| { - let marker = if action.enabled { "-" } else { "x" }; - format!("{marker} {}", action.label) - }) - .collect::>() - .join("\n"); let analysis = analyzer::ProjectAnalysis::from_context( &state.project, state.capabilities.clone(), state.runtime.clone(), ); - let content = format!( - "Project: {}\nStack: {}\nRoot: {}\n\nRuntime\nDocker: {}\nCluster: {}\n\nAvailable Actions:\n{}\n\nNext Steps:\n{}\n\nCtrl+P opens command palette. Press t to cycle themes.", + + // Split the bottom area into info panel and action list + let bottom_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(dashboard_layout[1]); + + // Left: project info + let info_content = format!( + "Project: {}\nStack: {}\nRoot: {}\n\nRuntime\nDocker: {}\nCluster: {}\n\nNext Steps:\n{}\n\nCtrl+P opens command palette.\nPress t to cycle themes.\nTab/←/→ to switch panes.", state.project.name, state.project.stack, state.project.root.display(), availability(state.runtime.docker_running), availability(state.runtime.cluster_connected), - if actions.is_empty() { "none".to_string() } else { actions }, render_short_list(&analysis.next_steps) ); render_panel( frame, - dashboard_layout[1], - " Project Dashboard ", - content, + bottom_layout[0], + " Project Info ", + info_content, palette, ); + + // Right: selectable action list + render_action_list(frame, bottom_layout[1], state, palette, Screen::Dashboard); } fn render_docker(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - let content = if state.capabilities.docker { - format!( - "Dockerfile detected\nDaemon: {}\n\nActions\n- Build Docker Image\n- Run Container\n- Docker Logs\n\n{}", + if state.capabilities.docker { + let docker_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(8), Constraint::Min(5)]) + .split(area); + + let info = format!( + "Dockerfile detected\nDaemon: {}\n\n{}", availability(state.runtime.docker_running), if state.runtime.docker_running { "Runtime actions are ready for the Docker engine implementation." } else { "Start Docker Desktop or the Docker service to enable runtime actions." } - ) + ); + render_panel(frame, docker_layout[0], " Docker Info ", info, palette); + render_action_list(frame, docker_layout[1], state, palette, Screen::Docker); } else { - empty_state( + let content = empty_state( "Docker Not Configured", "No Dockerfile was found.", "Generate or add a Dockerfile to unlock image and container workflows.", - ) - }; - render_panel(frame, area, " Docker ", content, palette); + ); + render_panel(frame, area, " Docker ", content, palette); + } } fn render_compose(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - let content = if state.capabilities.compose { - "Compose file detected\n\nActions\n- Compose Up\n- Compose Down\n- Compose Logs\n- Restart Services" - .to_string() + if state.capabilities.compose { + let compose_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(5), Constraint::Min(5)]) + .split(area); + + render_panel( + frame, + compose_layout[0], + " Compose Info ", + "Compose file detected".to_string(), + palette, + ); + render_action_list(frame, compose_layout[1], state, palette, Screen::Compose); } else { - empty_state( + let content = empty_state( "Compose Not Configured", "No docker-compose.yml or compose.yaml file was found.", "Add a Compose file to unlock multi-service workflows.", - ) - }; - render_panel(frame, area, " Compose ", content, palette); + ); + render_panel(frame, area, " Compose ", content, palette); + } } fn render_kubernetes(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - let content = if state.capabilities.kubernetes { - format!( - "Kubernetes manifests detected\nCluster: {}\n\nResources\n- Deployments\n- Pods\n- Services\n- Ingress\n- ConfigMaps\n- Secrets\n\n{}", + if state.capabilities.kubernetes { + let k8s_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(10), Constraint::Min(5)]) + .split(area); + + let info = format!( + "Kubernetes manifests detected\nCluster: {}\n\nResources\n- Deployments\n- Pods\n- Services\n\n{}", availability(state.runtime.cluster_connected), if state.runtime.cluster_connected { "Read-only resource views are ready for kube-rs integration." } else { "Connect a cluster or start Minikube to enable deployment actions." } - ) + ); + render_panel(frame, k8s_layout[0], " Kubernetes Info ", info, palette); + render_action_list(frame, k8s_layout[1], state, palette, Screen::Kubernetes); } else { - empty_state( + let content = empty_state( "Kubernetes Not Configured", "No deployment, service, ingress, or kustomization file was found.", "Generate or add manifests to unlock cluster workflows.", - ) - }; - render_panel(frame, area, " Kubernetes ", content, palette); + ); + render_panel(frame, area, " Kubernetes ", content, palette); + } } fn render_helm(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - let content = if state.capabilities.helm { - "Chart.yaml detected\n\nActions\n- Helm Install\n- Helm Upgrade\n- Helm Rollback" - .to_string() + if state.capabilities.helm { + let helm_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(5), Constraint::Min(5)]) + .split(area); + + render_panel( + frame, + helm_layout[0], + " Helm Info ", + "Chart.yaml detected".to_string(), + palette, + ); + render_action_list(frame, helm_layout[1], state, palette, Screen::Helm); } else { - empty_state( + let content = empty_state( "Helm Not Configured", "No Chart.yaml file was found.", "Add a chart to unlock Helm workflows.", - ) - }; - render_panel(frame, area, " Helm ", content, palette); + ); + render_panel(frame, area, " Helm ", content, palette); + } } fn render_deployments(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { let plan = deploy::pipeline::plan(&state.capabilities, &state.runtime); let layout = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(5), Constraint::Min(8)]) + .constraints([Constraint::Length(5), Constraint::Min(5), Constraint::Length(8)]) .split(area); let ready = plan.ready(); @@ -442,26 +546,39 @@ fn render_deployments(frame: &mut Frame, area: Rect, state: &AppState, palette: plan.render(), palette, ); + render_action_list(frame, layout[2], state, palette, Screen::Deployments); } fn render_monitoring(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - let content = if state.capabilities.monitoring { - format!( - "Health\nDocker: {}\nCluster: {}\n\nLogs\n- Application Logs\n- Docker Logs\n- Pod Logs\n\nMetrics\n- CPU Usage\n- Memory Usage\n- Network Usage\n\nEvents\nNo events collected yet.", + if state.capabilities.monitoring { + let mon_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(10), Constraint::Min(5)]) + .split(area); + + let info = format!( + "Health\nDocker: {}\nCluster: {}\n\nMetrics\n- CPU Usage\n- Memory Usage\n- Network Usage\n\nEvents\nNo events collected yet.", availability(state.runtime.docker_running), availability(state.runtime.cluster_connected) - ) + ); + render_panel(frame, mon_layout[0], " Monitoring Info ", info, palette); + render_action_list(frame, mon_layout[1], state, palette, Screen::Monitoring); } else { - empty_state( + let content = empty_state( "Monitoring Not Available", "No Docker, Compose, or Kubernetes assets were found.", "Add runtime configuration to unlock logs, health, metrics, and events.", - ) - }; - render_panel(frame, area, " Monitoring ", content, palette); + ); + render_panel(frame, area, " Monitoring ", content, palette); + } } fn render_settings(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { + let settings_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(12), Constraint::Min(5)]) + .split(area); + let content = format!( "Project: {}\nTheme: {}\nDefault Environment: {}\nRecent Projects: {}\n\nTheme Options\n{}\n\nPress t to cycle theme.", state.project.name, @@ -474,7 +591,8 @@ fn render_settings(frame: &mut Frame, area: Rect, state: &AppState, palette: the .collect::>() .join("\n") ); - render_panel(frame, area, " Settings ", content, palette); + render_panel(frame, settings_layout[0], " Settings ", content, palette); + render_action_list(frame, settings_layout[1], state, palette, Screen::Settings); } fn render_panel( @@ -493,6 +611,90 @@ fn render_panel( ); } +fn render_action_list( + frame: &mut Frame, + area: Rect, + state: &AppState, + palette: theme::Palette, + screen: Screen, +) { + let is_focused = state.ui.focus == FocusPane::Main; + let screen_actions = state.ui.screen_actions(&state.actions, screen); + + if screen_actions.is_empty() { + let border_style = if is_focused { + Style::default().fg(palette.accent) + } else { + Style::default().fg(palette.muted) + }; + frame.render_widget( + Paragraph::new("No actions available for this screen.\n\nUse Ctrl+P to open the command palette.") + .wrap(Wrap { trim: false }) + .block( + Block::default() + .title(" Actions ") + .borders(Borders::ALL) + .border_style(border_style), + ) + .style(Style::default().fg(palette.muted)), + area, + ); + return; + } + + let items = screen_actions + .iter() + .enumerate() + .map(|(index, action)| { + let is_selected = is_focused && index == state.ui.selected_action; + let marker = if is_selected { + "▸ " + } else { + " " + }; + + let style = if !action.enabled { + Style::default().fg(palette.muted) + } else if is_selected { + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text) + }; + + let suffix = if !action.enabled { + " (unavailable)" + } else { + "" + }; + + ListItem::new(Line::from(format!("{marker}{}{suffix}", action.label))).style(style) + }) + .collect::>(); + + let border_style = if is_focused { + Style::default().fg(palette.accent) + } else { + Style::default().fg(palette.muted) + }; + let title = if is_focused { + " Actions ● (Enter to run) " + } else { + " Actions (Tab to focus) " + }; + + frame.render_widget( + List::new(items).block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style), + ), + area, + ); +} + fn welcome_rect(area: Rect) -> Rect { let width_u32 = (area.width as u32 * 65 / 100) .max(60) diff --git a/src/ui/state.rs b/src/ui/state.rs index 9f4f171..e750bf2 100644 --- a/src/ui/state.rs +++ b/src/ui/state.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use crate::ui::theme::ThemeName; +use crate::{commands::palette::CommandAction, domain::screen::Screen, ui::theme::ThemeName}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UiPhase { @@ -26,6 +26,12 @@ impl FirstLaunchChoice { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusPane { + Sidebar, + Main, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Notification { pub level: NotificationLevel, @@ -111,6 +117,8 @@ pub struct UiState { pub picked_folder: Option, pub execution_output: Option>, pub execution_title: Option, + pub focus: FocusPane, + pub selected_action: usize, } impl UiState { @@ -129,6 +137,8 @@ impl UiState { picked_folder: None, execution_output: None, execution_title: None, + focus: FocusPane::Sidebar, + selected_action: 0, } } @@ -191,6 +201,49 @@ impl UiState { pub fn has_execution_output(&self) -> bool { self.execution_output.is_some() } + + /// Toggle focus between sidebar and main panel. + pub fn toggle_focus(&mut self) { + self.focus = match self.focus { + FocusPane::Sidebar => FocusPane::Main, + FocusPane::Main => FocusPane::Sidebar, + }; + } + + /// Move action selection down, wrapping around. + pub fn move_action_next(&mut self, total: usize) { + if total > 0 { + self.selected_action = (self.selected_action + 1) % total; + } + } + + /// Move action selection up, wrapping around. + pub fn move_action_previous(&mut self, total: usize) { + if total > 0 { + self.selected_action = if self.selected_action == 0 { + total - 1 + } else { + self.selected_action - 1 + }; + } + } + + /// Reset action selection when changing screens. + pub fn reset_action_selection(&mut self) { + self.selected_action = 0; + } + + /// Get the list of actions relevant to the given screen. + pub fn screen_actions<'a>( + &self, + actions: &'a [CommandAction], + screen: Screen, + ) -> Vec<&'a CommandAction> { + actions + .iter() + .filter(|a| a.screen == screen) + .collect() + } } #[cfg(test)] From 041e7bb53663752e652c11f66544060edfc8e5e6 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Tue, 2 Jun 2026 00:17:32 +0530 Subject: [PATCH 2/2] refactor(ui): satisfy CodeScene quality gates for dashboard module - Move welcome and scanning phase screens to new welcome.rs module - Introduce render_capability_screen to remove duplication across 5 layouts - Simplify run_loop key handler by extracting logic and sub-helpers - Simplify render_action_list by splitting into 3 specific layout helpers --- src/ui/dashboard.rs | 808 +++++++++++++++----------------------------- src/ui/mod.rs | 2 + src/ui/welcome.rs | 351 +++++++++++++++++++ 3 files changed, 631 insertions(+), 530 deletions(-) create mode 100644 src/ui/welcome.rs diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index 6095748..0347b3f 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -1,4 +1,4 @@ -use std::{io, path::PathBuf, time::Duration}; +use std::{io, time::Duration}; use crossterm::{ event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, @@ -20,10 +20,11 @@ use crate::{ project::analyzer, services::executor::{CommandExecutor, KdcExecutor}, ui::{ - command_palette, folder_picker, - state::{FirstLaunchChoice, FocusPane, Notification, NotificationLevel, UiPhase}, + command_palette, + state::{FocusPane, Notification, NotificationLevel, UiPhase}, statusbar, theme::{self, ThemeName}, + welcome, }, }; @@ -55,139 +56,121 @@ fn run_loop(terminal: &mut DefaultTerminal, state: &mut AppState) -> io::Result< if key.kind != KeyEventKind::Press { continue; } - - if state.ui.palette.open { - handle_palette_key(state, key.code); - continue; + if handle_key_event(state, key)? { + break; } + } + } + } + Ok(()) +} - if state.ui.phase == UiPhase::FirstLaunch { - if handle_first_launch_key(state, key.code)? { - break; - } - continue; - } +fn handle_key_event(state: &mut AppState, key: event::KeyEvent) -> io::Result { + if state.ui.palette.open { + handle_palette_key(state, key.code); + return Ok(false); + } - match (key.code, key.modifiers) { - (KeyCode::Char('q'), _) => break, - (KeyCode::Esc, _) if state.ui.has_execution_output() => { - state.ui.clear_execution_output() - } - (KeyCode::Char('p'), KeyModifiers::CONTROL) => state.ui.palette.open(), - (KeyCode::Char('r'), KeyModifiers::CONTROL) => { - refresh_project(state)?; - } - (KeyCode::Char('b'), KeyModifiers::CONTROL) => { - route_action(state, "docker.build") - } - (KeyCode::Char('d'), KeyModifiers::CONTROL) => { - route_action(state, "kubernetes.deploy") - } - (KeyCode::Char('l'), KeyModifiers::CONTROL) => { - router::route_to(state, Screen::Monitoring) - } - (KeyCode::Char('t'), _) => cycle_theme(state), - (KeyCode::Tab, _) | (KeyCode::BackTab, _) => { - state.ui.toggle_focus(); - } - (KeyCode::Left, _) => { - state.ui.focus = FocusPane::Sidebar; - } - (KeyCode::Right, _) => { - state.ui.focus = FocusPane::Main; - } - (KeyCode::Down, _) | (KeyCode::Char('j'), _) => { - state.ui.clear_execution_output(); - match state.ui.focus { - FocusPane::Sidebar => navigation::move_next(state), - FocusPane::Main => { - let total = state - .ui - .screen_actions(&state.actions, state.current_screen) - .len(); - state.ui.move_action_next(total); - } - } - } - (KeyCode::Up, _) | (KeyCode::Char('k'), _) => { - state.ui.clear_execution_output(); - match state.ui.focus { - FocusPane::Sidebar => navigation::move_previous(state), - FocusPane::Main => { - let total = state - .ui - .screen_actions(&state.actions, state.current_screen) - .len(); - state.ui.move_action_previous(total); - } - } - } - (KeyCode::Enter, _) => { - state.ui.clear_execution_output(); - match state.ui.focus { - FocusPane::Sidebar => { - state.ui.reset_action_selection(); - router::select_current_menu(state); - } - FocusPane::Main => { - let screen_actions: Vec<_> = state - .ui - .screen_actions(&state.actions, state.current_screen) - .iter() - .map(|a| (a.id.clone(), a.screen, a.label.clone(), a.enabled, a.reason.clone())) - .collect(); - if let Some((id, screen, label, enabled, reason)) = - screen_actions.get(state.ui.selected_action).cloned() - { - if enabled { - execute_action(state, &id, screen, &label); - } else { - state.ui.push_notification(Notification::warning( - reason.unwrap_or_else(|| "Action unavailable".to_string()), - )); - } - } - } - } - } - _ => {} - } - } + if state.ui.phase == UiPhase::FirstLaunch { + return welcome::handle_first_launch_key(state, key.code); + } + + match (key.code, key.modifiers) { + (KeyCode::Char('q'), _) => return Ok(true), + (KeyCode::Esc, _) if state.ui.has_execution_output() => { + state.ui.clear_execution_output() + } + (KeyCode::Char('p'), KeyModifiers::CONTROL) => state.ui.palette.open(), + (KeyCode::Char('r'), KeyModifiers::CONTROL) => { + refresh_project(state)?; + } + (KeyCode::Char('b'), KeyModifiers::CONTROL) => { + route_action(state, "docker.build") } + (KeyCode::Char('d'), KeyModifiers::CONTROL) => { + route_action(state, "kubernetes.deploy") + } + (KeyCode::Char('l'), KeyModifiers::CONTROL) => { + router::route_to(state, Screen::Monitoring) + } + (KeyCode::Char('t'), _) => cycle_theme(state), + (KeyCode::Tab, _) | (KeyCode::BackTab, _) => { + state.ui.toggle_focus(); + } + (KeyCode::Left, _) => { + state.ui.focus = FocusPane::Sidebar; + } + (KeyCode::Right, _) => { + state.ui.focus = FocusPane::Main; + } + (KeyCode::Down, _) | (KeyCode::Char('j'), _) => { + handle_down_key(state); + } + (KeyCode::Up, _) | (KeyCode::Char('k'), _) => { + handle_up_key(state); + } + (KeyCode::Enter, _) => { + handle_enter_key(state); + } + _ => {} } + Ok(false) +} - Ok(()) +fn handle_down_key(state: &mut AppState) { + state.ui.clear_execution_output(); + match state.ui.focus { + FocusPane::Sidebar => navigation::move_next(state), + FocusPane::Main => { + let total = state + .ui + .screen_actions(&state.actions, state.current_screen) + .len(); + state.ui.move_action_next(total); + } + } } -fn handle_first_launch_key(state: &mut AppState, code: KeyCode) -> io::Result { - match code { - KeyCode::Char('q') | KeyCode::Esc => Ok(true), - KeyCode::Down | KeyCode::Char('j') => { - state.ui.move_first_launch_next(); - Ok(false) +fn handle_up_key(state: &mut AppState) { + state.ui.clear_execution_output(); + match state.ui.focus { + FocusPane::Sidebar => navigation::move_previous(state), + FocusPane::Main => { + let total = state + .ui + .screen_actions(&state.actions, state.current_screen) + .len(); + state.ui.move_action_previous(total); } - KeyCode::Up | KeyCode::Char('k') => { - state.ui.move_first_launch_previous(); - Ok(false) + } +} + +fn handle_enter_key(state: &mut AppState) { + state.ui.clear_execution_output(); + match state.ui.focus { + FocusPane::Sidebar => { + state.ui.reset_action_selection(); + router::select_current_menu(state); } - KeyCode::Enter => { - match state.ui.selected_first_launch_choice() { - FirstLaunchChoice::UseCurrentFolder => { - state.ui.start_scanning(); - state.notify_info("Scanning current folder"); - } - FirstLaunchChoice::BrowseFolder => { - if let Some(path) = folder_picker::pick_folder()? { - reload_project(state, path)?; - } else { - state.notify_warning("Folder selection cancelled"); - } + FocusPane::Main => { + let screen_actions: Vec<_> = state + .ui + .screen_actions(&state.actions, state.current_screen) + .iter() + .map(|a| (a.id.clone(), a.screen, a.label.clone(), a.enabled, a.reason.clone())) + .collect(); + if let Some((id, screen, label, enabled, reason)) = + screen_actions.get(state.ui.selected_action).cloned() + { + if enabled { + execute_action(state, &id, screen, &label); + } else { + state.ui.push_notification(Notification::warning( + reason.unwrap_or_else(|| "Action unavailable".to_string()), + )); } - FirstLaunchChoice::Exit => return Ok(true), } - Ok(false) } - _ => Ok(false), } } @@ -196,12 +179,12 @@ fn render(frame: &mut Frame, state: &AppState) { match state.ui.phase { UiPhase::FirstLaunch => { - render_first_launch(frame, frame.area(), state, palette); + welcome::render_first_launch(frame, frame.area(), state, palette); render_notifications(frame, state, palette); return; } UiPhase::Scanning => { - render_scanning(frame, frame.area(), state, palette); + welcome::render_scanning(frame, frame.area(), state, palette); render_notifications(frame, state, palette); return; } @@ -409,110 +392,123 @@ fn render_dashboard(frame: &mut Frame, area: Rect, state: &AppState, palette: th render_action_list(frame, bottom_layout[1], state, palette, Screen::Dashboard); } -fn render_docker(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - if state.capabilities.docker { - let docker_layout = Layout::default() +fn render_capability_screen( + frame: &mut Frame, + area: Rect, + state: &AppState, + palette: theme::Palette, + has_capability: bool, + screen: Screen, + info_title: &str, + info_content: String, + panel_title: &str, + empty_title: &str, + empty_body: &str, + empty_suggestion: &str, + info_height: u16, +) { + if has_capability { + let layout = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(8), Constraint::Min(5)]) + .constraints([Constraint::Length(info_height), Constraint::Min(5)]) .split(area); - let info = format!( - "Dockerfile detected\nDaemon: {}\n\n{}", - availability(state.runtime.docker_running), - if state.runtime.docker_running { - "Runtime actions are ready for the Docker engine implementation." - } else { - "Start Docker Desktop or the Docker service to enable runtime actions." - } - ); - render_panel(frame, docker_layout[0], " Docker Info ", info, palette); - render_action_list(frame, docker_layout[1], state, palette, Screen::Docker); + render_panel(frame, layout[0], info_title, info_content, palette); + render_action_list(frame, layout[1], state, palette, screen); } else { - let content = empty_state( - "Docker Not Configured", - "No Dockerfile was found.", - "Generate or add a Dockerfile to unlock image and container workflows.", - ); - render_panel(frame, area, " Docker ", content, palette); + let content = empty_state(empty_title, empty_body, empty_suggestion); + render_panel(frame, area, panel_title, content, palette); } } -fn render_compose(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - if state.capabilities.compose { - let compose_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(5), Constraint::Min(5)]) - .split(area); +fn render_docker(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { + let info = format!( + "Dockerfile detected\nDaemon: {}\n\n{}", + availability(state.runtime.docker_running), + if state.runtime.docker_running { + "Runtime actions are ready for the Docker engine implementation." + } else { + "Start Docker Desktop or the Docker service to enable runtime actions." + } + ); + render_capability_screen( + frame, + area, + state, + palette, + state.capabilities.docker, + Screen::Docker, + " Docker Info ", + info, + " Docker ", + "Docker Not Configured", + "No Dockerfile was found.", + "Generate or add a Dockerfile to unlock image and container workflows.", + 8, + ); +} - render_panel( - frame, - compose_layout[0], - " Compose Info ", - "Compose file detected".to_string(), - palette, - ); - render_action_list(frame, compose_layout[1], state, palette, Screen::Compose); - } else { - let content = empty_state( - "Compose Not Configured", - "No docker-compose.yml or compose.yaml file was found.", - "Add a Compose file to unlock multi-service workflows.", - ); - render_panel(frame, area, " Compose ", content, palette); - } +fn render_compose(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { + render_capability_screen( + frame, + area, + state, + palette, + state.capabilities.compose, + Screen::Compose, + " Compose Info ", + "Compose file detected".to_string(), + " Compose ", + "Compose Not Configured", + "No docker-compose.yml or compose.yaml file was found.", + "Add a Compose file to unlock multi-service workflows.", + 5, + ); } fn render_kubernetes(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - if state.capabilities.kubernetes { - let k8s_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(10), Constraint::Min(5)]) - .split(area); - - let info = format!( - "Kubernetes manifests detected\nCluster: {}\n\nResources\n- Deployments\n- Pods\n- Services\n\n{}", - availability(state.runtime.cluster_connected), - if state.runtime.cluster_connected { - "Read-only resource views are ready for kube-rs integration." - } else { - "Connect a cluster or start Minikube to enable deployment actions." - } - ); - render_panel(frame, k8s_layout[0], " Kubernetes Info ", info, palette); - render_action_list(frame, k8s_layout[1], state, palette, Screen::Kubernetes); - } else { - let content = empty_state( - "Kubernetes Not Configured", - "No deployment, service, ingress, or kustomization file was found.", - "Generate or add manifests to unlock cluster workflows.", - ); - render_panel(frame, area, " Kubernetes ", content, palette); - } + let info = format!( + "Kubernetes manifests detected\nCluster: {}\n\nResources\n- Deployments\n- Pods\n- Services\n\n{}", + availability(state.runtime.cluster_connected), + if state.runtime.cluster_connected { + "Read-only resource views are ready for kube-rs integration." + } else { + "Connect a cluster or start Minikube to enable deployment actions." + } + ); + render_capability_screen( + frame, + area, + state, + palette, + state.capabilities.kubernetes, + Screen::Kubernetes, + " Kubernetes Info ", + info, + " Kubernetes ", + "Kubernetes Not Configured", + "No deployment, service, ingress, or kustomization file was found.", + "Generate or add manifests to unlock cluster workflows.", + 10, + ); } fn render_helm(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - if state.capabilities.helm { - let helm_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(5), Constraint::Min(5)]) - .split(area); - - render_panel( - frame, - helm_layout[0], - " Helm Info ", - "Chart.yaml detected".to_string(), - palette, - ); - render_action_list(frame, helm_layout[1], state, palette, Screen::Helm); - } else { - let content = empty_state( - "Helm Not Configured", - "No Chart.yaml file was found.", - "Add a chart to unlock Helm workflows.", - ); - render_panel(frame, area, " Helm ", content, palette); - } + render_capability_screen( + frame, + area, + state, + palette, + state.capabilities.helm, + Screen::Helm, + " Helm Info ", + "Chart.yaml detected".to_string(), + " Helm ", + "Helm Not Configured", + "No Chart.yaml file was found.", + "Add a chart to unlock Helm workflows.", + 5, + ); } fn render_deployments(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { @@ -550,27 +546,26 @@ fn render_deployments(frame: &mut Frame, area: Rect, state: &AppState, palette: } fn render_monitoring(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - if state.capabilities.monitoring { - let mon_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(10), Constraint::Min(5)]) - .split(area); - - let info = format!( - "Health\nDocker: {}\nCluster: {}\n\nMetrics\n- CPU Usage\n- Memory Usage\n- Network Usage\n\nEvents\nNo events collected yet.", - availability(state.runtime.docker_running), - availability(state.runtime.cluster_connected) - ); - render_panel(frame, mon_layout[0], " Monitoring Info ", info, palette); - render_action_list(frame, mon_layout[1], state, palette, Screen::Monitoring); - } else { - let content = empty_state( - "Monitoring Not Available", - "No Docker, Compose, or Kubernetes assets were found.", - "Add runtime configuration to unlock logs, health, metrics, and events.", - ); - render_panel(frame, area, " Monitoring ", content, palette); - } + let info = format!( + "Health\nDocker: {}\nCluster: {}\n\nMetrics\n- CPU Usage\n- Memory Usage\n- Network Usage\n\nEvents\nNo events collected yet.", + availability(state.runtime.docker_running), + availability(state.runtime.cluster_connected) + ); + render_capability_screen( + frame, + area, + state, + palette, + state.capabilities.monitoring, + Screen::Monitoring, + " Monitoring Info ", + info, + " Monitoring ", + "Monitoring Not Available", + "No Docker, Compose, or Kubernetes assets were found.", + "Add runtime configuration to unlock logs, health, metrics, and events.", + 10, + ); } fn render_settings(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { @@ -622,36 +617,45 @@ fn render_action_list( let screen_actions = state.ui.screen_actions(&state.actions, screen); if screen_actions.is_empty() { - let border_style = if is_focused { - Style::default().fg(palette.accent) - } else { - Style::default().fg(palette.muted) - }; - frame.render_widget( - Paragraph::new("No actions available for this screen.\n\nUse Ctrl+P to open the command palette.") - .wrap(Wrap { trim: false }) - .block( - Block::default() - .title(" Actions ") - .borders(Borders::ALL) - .border_style(border_style), - ) - .style(Style::default().fg(palette.muted)), - area, - ); - return; + render_empty_actions(frame, area, is_focused, palette); + } else { + let items = build_action_list_items(&screen_actions, is_focused, state.ui.selected_action, palette); + render_active_actions(frame, area, items, is_focused, palette); } +} - let items = screen_actions +fn render_empty_actions(frame: &mut Frame, area: Rect, is_focused: bool, palette: theme::Palette) { + let border_style = if is_focused { + Style::default().fg(palette.accent) + } else { + Style::default().fg(palette.muted) + }; + frame.render_widget( + Paragraph::new("No actions available for this screen.\n\nUse Ctrl+P to open the command palette.") + .wrap(Wrap { trim: false }) + .block( + Block::default() + .title(" Actions ") + .borders(Borders::ALL) + .border_style(border_style), + ) + .style(Style::default().fg(palette.muted)), + area, + ); +} + +fn build_action_list_items( + actions: &[&crate::commands::palette::CommandAction], + is_focused: bool, + selected_idx: usize, + palette: theme::Palette, +) -> Vec> { + actions .iter() .enumerate() .map(|(index, action)| { - let is_selected = is_focused && index == state.ui.selected_action; - let marker = if is_selected { - "▸ " - } else { - " " - }; + let is_selected = is_focused && index == selected_idx; + let marker = if is_selected { "▸ " } else { " " }; let style = if !action.enabled { Style::default().fg(palette.muted) @@ -663,16 +667,20 @@ fn render_action_list( Style::default().fg(palette.text) }; - let suffix = if !action.enabled { - " (unavailable)" - } else { - "" - }; + let suffix = if !action.enabled { " (unavailable)" } else { "" }; ListItem::new(Line::from(format!("{marker}{}{suffix}", action.label))).style(style) }) - .collect::>(); + .collect() +} +fn render_active_actions( + frame: &mut Frame, + area: Rect, + items: Vec>, + is_focused: bool, + palette: theme::Palette, +) { let border_style = if is_focused { Style::default().fg(palette.accent) } else { @@ -695,249 +703,7 @@ fn render_action_list( ); } -fn welcome_rect(area: Rect) -> Rect { - let width_u32 = (area.width as u32 * 65 / 100) - .max(60) - .min(area.width as u32); - let height_u32 = 25u32.min(area.height as u32).max(20); - - let width = width_u32 as u16; - let height = height_u32 as u16; - let x = area.width.saturating_sub(width) / 2; - let y = area.height.saturating_sub(height) / 2; - Rect { - x, - y, - width, - height, - } -} - -fn render_outer_block( - frame: &mut Frame, - welcome_area: Rect, - palette: theme::Palette, -) -> Block<'static> { - let outer_block = Block::default().borders(Borders::ALL).title(Span::styled( - " KDC - Welcome ", - Style::default() - .fg(palette.accent) - .add_modifier(Modifier::BOLD), - )); - frame.render_widget(Clear, welcome_area); - frame.render_widget(outer_block.clone(), welcome_area); - outer_block -} - -fn render_ascii_banner(frame: &mut Frame, chunk: Rect, palette: theme::Palette) { - let ascii_art = vec![ - Line::from(Span::styled( - " _ ______ ____ ", - Style::default() - .fg(palette.accent) - .add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - " | |/ / _ \\ / ___|", - Style::default() - .fg(palette.accent) - .add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - " | ' /| | | | | ", - Style::default() - .fg(palette.accent) - .add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - " | . \\| |_| | |___ ", - Style::default() - .fg(palette.accent) - .add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - " |_|\\_\\____/ \\____|", - Style::default() - .fg(palette.accent) - .add_modifier(Modifier::BOLD), - )), - ]; - frame.render_widget( - Paragraph::new(ascii_art).alignment(Alignment::Center), - chunk, - ); -} - -fn render_subtitle(frame: &mut Frame, chunk: Rect, palette: theme::Palette) { - let subtitle_info = vec![ - Line::from(Span::styled( - "Kubernetes & Docker Commander like a boss.", - Style::default().fg(palette.text), - )), - Line::from(Span::styled( - "https://github.com/KDM-cli/kdc-cli", - Style::default().fg(palette.muted), - )), - Line::from(vec![ - Span::raw("[with "), - Span::styled("♥", Style::default().fg(palette.danger)), - Span::raw(" by "), - Span::styled("@utkarsh232005", Style::default().fg(palette.success)), - Span::raw("]"), - ]), - ]; - frame.render_widget( - Paragraph::new(subtitle_info).alignment(Alignment::Center), - chunk, - ); -} - -fn capability_line(label: &str, present: bool, palette: theme::Palette) -> Line<'static> { - Line::from(vec![ - Span::styled(format!(" {}: ", label), Style::default().fg(palette.muted)), - Span::styled( - if present { "Found" } else { "Missing" }, - Style::default().fg(if present { - palette.success - } else { - palette.warning - }), - ), - ]) -} - -fn render_capabilities_card( - frame: &mut Frame, - chunk: Rect, - state: &AppState, - palette: theme::Palette, -) { - let mut details = Vec::new(); - details.push(Line::from(vec![ - Span::styled(" Root: ", Style::default().fg(palette.muted)), - Span::styled( - format!("{}", state.project.root.display()), - Style::default().fg(palette.text), - ), - ])); - details.push(Line::from(vec![ - Span::styled(" Stack: ", Style::default().fg(palette.muted)), - Span::styled( - format!("{}", state.project.stack), - Style::default().fg(palette.text), - ), - ])); - details.push(capability_line( - "Dockerfile", - state.capabilities.docker, - palette, - )); - details.push(capability_line( - "Compose", - state.capabilities.compose, - palette, - )); - details.push(capability_line( - "Kubernetes", - state.capabilities.kubernetes, - palette, - )); - details.push(capability_line( - "Helm Chart", - state.capabilities.helm, - palette, - )); - - frame.render_widget( - Paragraph::new(details) - .block( - Block::default() - .title(" Current Directory Details ") - .borders(Borders::ALL), - ) - .style(Style::default().fg(palette.text)), - chunk, - ); -} - -fn render_first_launch(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - let welcome_area = welcome_rect(area); - let outer_block = render_outer_block(frame, welcome_area, palette); - let inner_area = outer_block.inner(welcome_area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), // ASCII art - Constraint::Length(4), // Subtitle, link, author - Constraint::Length(8), // Project card - Constraint::Min(5), // Options - ]) - .split(inner_area); - - render_ascii_banner(frame, chunks[0], palette); - render_subtitle(frame, chunks[1], palette); - render_capabilities_card(frame, chunks[2], state, palette); - - // 4. Action/Choice List - let choices = [ - FirstLaunchChoice::UseCurrentFolder, - FirstLaunchChoice::BrowseFolder, - FirstLaunchChoice::Exit, - ]; - let items = choices - .iter() - .enumerate() - .map(|(index, choice)| { - let marker = if state.ui.first_launch_choice == index { - "> " - } else { - " " - }; - let style = if state.ui.first_launch_choice == index { - Style::default() - .fg(palette.accent) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(palette.text) - }; - ListItem::new(format!("{marker}{}", choice.label())).style(style) - }) - .collect::>(); - - frame.render_widget( - List::new(items).block(Block::default().title(" Actions ").borders(Borders::ALL)), - chunks[3], - ); -} - -fn render_scanning(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { - let area = centered_rect(62, 36, area); - let content = format!( - "Scanning Project...\n\nProject: {}\nRoot: {}\n\nDockerfile: {}\nCompose: {}\nKubernetes: {}\nHelm: {}\nStack: {}", - state.project.name, - state.project.root.display(), - found(state.capabilities.docker), - found(state.capabilities.compose), - found(state.capabilities.kubernetes), - found(state.capabilities.helm), - state.project.stack - ); - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(8), Constraint::Length(3)]) - .split(area); - frame.render_widget(Clear, area); - render_panel(frame, layout[0], " Project Scan ", content, palette); - frame.render_widget( - Gauge::default() - .block(Block::default().borders(Borders::ALL)) - .gauge_style(Style::default().fg(palette.accent)) - .percent(state.ui.scan_progress), - layout[1], - ); -} fn render_command_palette( frame: &mut Frame, @@ -1140,17 +906,6 @@ fn refresh_project(state: &mut AppState) -> io::Result<()> { Ok(()) } -fn reload_project(state: &mut AppState, path: PathBuf) -> io::Result<()> { - let mut new_state = startup::initialize(path.clone()).map_err(io::Error::other)?; - new_state.ui.active_theme = state.ui.active_theme; - new_state.ui.picked_folder = Some(path.clone()); - new_state.ui.start_scanning(); - new_state - .ui - .push_notification(Notification::info(format!("Selected {}", path.display()))); - *state = new_state; - Ok(()) -} fn cycle_theme(state: &mut AppState) { state.ui.active_theme = state.ui.active_theme.next(); @@ -1204,13 +959,6 @@ fn availability(value: bool) -> &'static str { } } -fn found(value: bool) -> &'static str { - if value { - "found" - } else { - "missing" - } -} fn render_short_list(values: &[String]) -> String { if values.is_empty() { @@ -1274,15 +1022,15 @@ mod tests { crate::utils::test_support::set_mock_path(); let mut state = crate::app::startup::initialize(std::path::PathBuf::from(".")).unwrap(); state.ui.first_launch_choice = 0; - let res = handle_first_launch_key(&mut state, KeyCode::Down); + let res = welcome::handle_first_launch_key(&mut state, KeyCode::Down); assert!(res.is_ok()); assert_eq!(state.ui.first_launch_choice, 1); - let res = handle_first_launch_key(&mut state, KeyCode::Up); + let res = welcome::handle_first_launch_key(&mut state, KeyCode::Up); assert!(res.is_ok()); assert_eq!(state.ui.first_launch_choice, 0); - let res = handle_first_launch_key(&mut state, KeyCode::Enter); + let res = welcome::handle_first_launch_key(&mut state, KeyCode::Enter); assert!(res.is_ok()); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5fb21a7..5d299c3 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,4 +7,6 @@ pub mod sidebar; pub mod state; pub mod statusbar; pub mod theme; +pub mod welcome; pub mod widgets; + diff --git a/src/ui/welcome.rs b/src/ui/welcome.rs new file mode 100644 index 0000000..0c7a787 --- /dev/null +++ b/src/ui/welcome.rs @@ -0,0 +1,351 @@ +use std::{io, path::PathBuf}; + +use crossterm::event::KeyCode; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Gauge, List, ListItem, Paragraph, Wrap}, + Frame, +}; + +use crate::{ + app::{startup, state::AppState}, + ui::{ + folder_picker, + state::{FirstLaunchChoice, Notification}, + theme, + }, +}; + +pub fn handle_first_launch_key(state: &mut AppState, code: KeyCode) -> io::Result { + match code { + KeyCode::Char('q') | KeyCode::Esc => Ok(true), + KeyCode::Down | KeyCode::Char('j') => { + state.ui.move_first_launch_next(); + Ok(false) + } + KeyCode::Up | KeyCode::Char('k') => { + state.ui.move_first_launch_previous(); + Ok(false) + } + KeyCode::Enter => { + match state.ui.selected_first_launch_choice() { + FirstLaunchChoice::UseCurrentFolder => { + state.ui.start_scanning(); + state.notify_info("Scanning current folder"); + } + FirstLaunchChoice::BrowseFolder => { + if let Some(path) = folder_picker::pick_folder()? { + reload_project(state, path)?; + } else { + state.notify_warning("Folder selection cancelled"); + } + } + FirstLaunchChoice::Exit => return Ok(true), + } + Ok(false) + } + _ => Ok(false), + } +} + +fn reload_project(state: &mut AppState, path: PathBuf) -> io::Result<()> { + let mut new_state = startup::initialize(path.clone()).map_err(io::Error::other)?; + new_state.ui.active_theme = state.ui.active_theme; + new_state.ui.picked_folder = Some(path.clone()); + new_state.ui.start_scanning(); + new_state + .ui + .push_notification(Notification::info(format!("Selected {}", path.display()))); + *state = new_state; + Ok(()) +} + +pub fn render_first_launch(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { + let welcome_area = welcome_rect(area); + let outer_block = render_outer_block(frame, welcome_area, palette); + let inner_area = outer_block.inner(welcome_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), // ASCII art + Constraint::Length(4), // Subtitle, link, author + Constraint::Length(8), // Project card + Constraint::Min(5), // Options + ]) + .split(inner_area); + + render_ascii_banner(frame, chunks[0], palette); + render_subtitle(frame, chunks[1], palette); + render_capabilities_card(frame, chunks[2], state, palette); + + // 4. Action/Choice List + let choices = [ + FirstLaunchChoice::UseCurrentFolder, + FirstLaunchChoice::BrowseFolder, + FirstLaunchChoice::Exit, + ]; + let items = choices + .iter() + .enumerate() + .map(|(index, choice)| { + let marker = if state.ui.first_launch_choice == index { + "> " + } else { + " " + }; + let style = if state.ui.first_launch_choice == index { + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.text) + }; + ListItem::new(format!("{marker}{}", choice.label())).style(style) + }) + .collect::>(); + + frame.render_widget( + List::new(items).block(Block::default().title(" Actions ").borders(Borders::ALL)), + chunks[3], + ); +} + +pub fn render_scanning(frame: &mut Frame, area: Rect, state: &AppState, palette: theme::Palette) { + let area = centered_rect(62, 36, area); + let content = format!( + "Scanning Project...\n\nProject: {}\nRoot: {}\n\nDockerfile: {}\nCompose: {}\nKubernetes: {}\nHelm: {}\nStack: {}", + state.project.name, + state.project.root.display(), + found(state.capabilities.docker), + found(state.capabilities.compose), + found(state.capabilities.kubernetes), + found(state.capabilities.helm), + state.project.stack + ); + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(8), Constraint::Length(3)]) + .split(area); + + frame.render_widget(Clear, area); + render_panel(frame, layout[0], " Project Scan ", content, palette); + frame.render_widget( + Gauge::default() + .block(Block::default().borders(Borders::ALL)) + .gauge_style(Style::default().fg(palette.accent)) + .percent(state.ui.scan_progress), + layout[1], + ); +} + +fn welcome_rect(area: Rect) -> Rect { + let width_u32 = (area.width as u32 * 65 / 100) + .max(60) + .min(area.width as u32); + let height_u32 = 25u32.min(area.height as u32).max(20); + + let width = width_u32 as u16; + let height = height_u32 as u16; + let x = area.width.saturating_sub(width) / 2; + let y = area.height.saturating_sub(height) / 2; + Rect { + x, + y, + width, + height, + } +} + +fn render_outer_block( + frame: &mut Frame, + welcome_area: Rect, + palette: theme::Palette, +) -> Block<'static> { + let outer_block = Block::default().borders(Borders::ALL).title(Span::styled( + " KDC - Welcome ", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )); + frame.render_widget(Clear, welcome_area); + frame.render_widget(outer_block.clone(), welcome_area); + outer_block +} + +fn render_ascii_banner(frame: &mut Frame, chunk: Rect, palette: theme::Palette) { + let ascii_art = vec![ + Line::from(Span::styled( + " _ ______ ____ ", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + " | |/ / _ \\ / ___|", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + " | ' /| | | | | ", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + " | . \\| |_| | |___ ", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + " |_|\\_\\____/ \\____|", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )), + ]; + frame.render_widget( + Paragraph::new(ascii_art).alignment(Alignment::Center), + chunk, + ); +} + +fn render_subtitle(frame: &mut Frame, chunk: Rect, palette: theme::Palette) { + let subtitle_info = vec![ + Line::from(Span::styled( + "Kubernetes & Docker Commander like a boss.", + Style::default().fg(palette.text), + )), + Line::from(Span::styled( + "https://github.com/KDM-cli/kdc-cli", + Style::default().fg(palette.muted), + )), + Line::from(vec![ + Span::raw("[with "), + Span::styled("♥", Style::default().fg(palette.danger)), + Span::raw(" by "), + Span::styled("@utkarsh232005", Style::default().fg(palette.success)), + Span::raw("]"), + ]), + ]; + frame.render_widget( + Paragraph::new(subtitle_info).alignment(Alignment::Center), + chunk, + ); +} + +fn capability_line(label: &str, present: bool, palette: theme::Palette) -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {}: ", label), Style::default().fg(palette.muted)), + Span::styled( + if present { "Found" } else { "Missing" }, + Style::default().fg(if present { + palette.success + } else { + palette.warning + }), + ), + ]) +} + +fn render_capabilities_card( + frame: &mut Frame, + chunk: Rect, + state: &AppState, + palette: theme::Palette, +) { + let mut details = Vec::new(); + details.push(Line::from(vec![ + Span::styled(" Root: ", Style::default().fg(palette.muted)), + Span::styled( + format!("{}", state.project.root.display()), + Style::default().fg(palette.text), + ), + ])); + details.push(Line::from(vec![ + Span::styled(" Stack: ", Style::default().fg(palette.muted)), + Span::styled( + format!("{}", state.project.stack), + Style::default().fg(palette.text), + ), + ])); + details.push(capability_line( + "Dockerfile", + state.capabilities.docker, + palette, + )); + details.push(capability_line( + "Compose", + state.capabilities.compose, + palette, + )); + details.push(capability_line( + "Kubernetes", + state.capabilities.kubernetes, + palette, + )); + details.push(capability_line( + "Helm Chart", + state.capabilities.helm, + palette, + )); + + frame.render_widget( + Paragraph::new(details) + .block( + Block::default() + .title(" Current Directory Details ") + .borders(Borders::ALL), + ) + .style(Style::default().fg(palette.text)), + chunk, + ); +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1]); + horizontal[1] +} + +fn found(value: bool) -> &'static str { + if value { + "found" + } else { + "missing" + } +} + +fn render_panel( + frame: &mut Frame, + area: Rect, + title: &str, + content: String, + palette: theme::Palette, +) { + frame.render_widget( + Paragraph::new(content) + .wrap(Wrap { trim: false }) + .block(Block::default().title(title).borders(Borders::ALL)) + .style(Style::default().fg(palette.text).bg(palette.background)), + area, + ); +}