use anyhow::Result;
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use tokio::time::{Duration, Instant};
use crate::{
chat::ChatComponent,
components::{CommandPalette, Dialog, StatusBar},
config::Config,
events::{AppEvent, EventHandler, InputEvent, KeybindHandler, MouseHandler},
file_viewer::FileViewer,
layout::{LayoutManager, PopupLayout},
renderer::Renderer,
theme::ThemeManager,
};
pub struct App {
config: Config,
theme_manager: ThemeManager,
event_handler: EventHandler,
keybind_handler: KeybindHandler,
mouse_handler: MouseHandler,
layout_manager: LayoutManager,
chat: ChatComponent,
file_viewer: FileViewer,
status_bar: StatusBar,
command_palette: CommandPalette,
active_dialog: Option<Dialog>,
state: AppState,
last_render: Instant,
target_fps: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AppState {
Running,
Quitting,
Help,
CommandPalette,
Dialog,
FileViewer,
Chat,
}
impl App {
pub async fn new(config: Config) -> Result<Self> {
let theme_manager = ThemeManager::default();
let event_handler = EventHandler::new();
let mut keybind_handler = KeybindHandler::new();
keybind_handler.set_leader_key(config.keybinds.leader.clone());
for (action, key) in &config.keybinds.bindings {
keybind_handler.bind(key.clone(), action.clone());
}
let mouse_handler = MouseHandler::new();
let layout_manager = LayoutManager::new(ratatui::layout::Rect::new(0, 0, 80, 24));
let chat = ChatComponent::new(&config.chat, theme_manager.current_theme());
let file_viewer = FileViewer::new(&config.file_viewer, theme_manager.current_theme());
let status_bar = StatusBar::new(theme_manager.current_theme());
let command_palette = CommandPalette::new(theme_manager.current_theme());
Ok(Self {
config,
theme_manager,
event_handler,
keybind_handler,
mouse_handler,
layout_manager,
chat,
file_viewer,
status_bar,
command_palette,
active_dialog: None,
state: AppState::Running,
last_render: Instant::now(),
target_fps: 60,
})
}
pub async fn run(&mut self) -> Result<()> {
let mut terminal = crate::init_terminal()?;
while self.state != AppState::Quitting {
if let Some(event) = self.event_handler.try_next() {
self.handle_event(event).await?;
}
let now = Instant::now();
let frame_duration = Duration::from_millis(1000 / self.target_fps);
if now.duration_since(self.last_render) >= frame_duration {
self.render(&mut terminal)?;
self.last_render = now;
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
crate::restore_terminal(&mut terminal)?;
Ok(())
}
async fn handle_event(&mut self, event: AppEvent) -> Result<()> {
match event {
AppEvent::Input(input_event) => {
self.handle_input_event(input_event).await?;
}
AppEvent::Resize(width, height) => {
let new_area = ratatui::layout::Rect::new(0, 0, width, height);
self.layout_manager.resize(new_area);
}
AppEvent::Quit => {
self.state = AppState::Quitting;
}
AppEvent::Tick => {
self.update_components().await?;
}
AppEvent::Custom(message) => {
self.handle_custom_event(message).await?;
}
}
Ok(())
}
async fn handle_input_event(&mut self, event: InputEvent) -> Result<()> {
match event {
InputEvent::Key(key_event) => {
if let Some(action) = self.keybind_handler.handle_key(&key_event) {
self.execute_action(&action).await?;
} else {
self.route_key_event(key_event).await?;
}
}
InputEvent::Mouse(mouse_event) => {
let action = self.mouse_handler.handle_mouse(&mouse_event);
self.handle_mouse_action(action).await?;
}
InputEvent::Paste(data) => {
if self.state == AppState::Chat {
self.chat.handle_paste(data).await?;
}
}
InputEvent::FocusGained | InputEvent::FocusLost => {
}
}
Ok(())
}
async fn execute_action(&mut self, action: &str) -> Result<()> {
match action {
"quit" => {
self.state = AppState::Quitting;
}
"help" => {
self.toggle_help();
}
"command_palette" => {
self.toggle_command_palette();
}
"send_message" => {
if self.state == AppState::Chat {
self.chat.send_message().await?;
}
}
"new_line" => {
if self.state == AppState::Chat {
self.chat.insert_newline();
}
}
"clear_input" => {
if self.state == AppState::Chat {
self.chat.clear_input();
}
}
"open_file" => {
self.open_file_dialog();
}
"close_file" => {
self.file_viewer.close_file();
if self.state == AppState::FileViewer {
self.state = AppState::Chat;
}
}
"toggle_diff" => {
self.file_viewer.toggle_diff_style();
}
"scroll_up" => {
self.handle_scroll(true).await?;
}
"scroll_down" => {
self.handle_scroll(false).await?;
}
"page_up" => {
self.handle_page_scroll(true).await?;
}
"page_down" => {
self.handle_page_scroll(false).await?;
}
_ => {
}
}
Ok(())
}
async fn route_key_event(&mut self, key_event: crossterm::event::KeyEvent) -> Result<()> {
match self.state {
AppState::Chat => {
self.chat.handle_key_event(key_event).await?;
}
AppState::FileViewer => {
self.file_viewer.handle_key_event(key_event).await?;
}
AppState::CommandPalette => {
if let Some(result) = self.command_palette.handle_key_event(key_event).await? {
self.execute_command_palette_result(result).await?;
self.state = AppState::Chat;
}
}
AppState::Dialog => {
if let Some(ref mut dialog) = self.active_dialog {
if let Some(result) = dialog.handle_key_event(key_event).await? {
self.handle_dialog_result(result).await?;
self.active_dialog = None;
self.state = AppState::Chat;
}
}
}
_ => {}
}
Ok(())
}
async fn handle_mouse_action(&mut self, action: crate::events::MouseAction) -> Result<()> {
use crate::events::MouseAction;
match action {
MouseAction::LeftClick(x, y) => {
if self.layout_manager.main_area.intersects(ratatui::layout::Rect::new(x, y, 1, 1)) {
if self.file_viewer.is_visible() {
self.state = AppState::FileViewer;
} else {
self.state = AppState::Chat;
}
}
}
MouseAction::ScrollUp(x, y) => {
if self.is_in_scrollable_area(x, y) {
self.handle_scroll(true).await?;
}
}
MouseAction::ScrollDown(x, y) => {
if self.is_in_scrollable_area(x, y) {
self.handle_scroll(false).await?;
}
}
_ => {}
}
Ok(())
}
async fn update_components(&mut self) -> Result<()> {
self.chat.update().await?;
self.file_viewer.update().await?;
self.status_bar.update(&self.state).await?;
Ok(())
}
async fn handle_custom_event(&mut self, message: String) -> Result<()> {
match message.as_str() {
"theme_changed" => {
self.update_theme();
}
"file_opened" => {
self.state = AppState::FileViewer;
}
_ => {}
}
Ok(())
}
fn render(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
terminal.draw(|frame| {
let mut renderer = Renderer::new(frame, self.theme_manager.current_theme());
self.render_main_layout(&mut renderer);
self.render_overlays(&mut renderer);
})?;
Ok(())
}
fn render_main_layout(&mut self, renderer: &mut Renderer) {
self.status_bar.render(renderer, self.layout_manager.status_area);
if self.file_viewer.is_visible() {
if let Some(side_panel) = self.layout_manager.side_panel {
self.chat.render(renderer, self.layout_manager.main_area);
self.file_viewer.render(renderer, side_panel);
} else {
self.file_viewer.render(renderer, self.layout_manager.main_area);
}
} else {
self.chat.render(renderer, self.layout_manager.main_area);
}
self.chat.render_input(renderer, self.layout_manager.input_area);
}
fn render_overlays(&mut self, renderer: &mut Renderer) {
match self.state {
AppState::CommandPalette => {
let popup_area = PopupLayout::centered(
self.layout_manager.terminal_area,
60,
15,
);
self.command_palette.render(renderer, popup_area);
}
AppState::Dialog => {
if let Some(ref mut dialog) = self.active_dialog {
let popup_area = PopupLayout::centered(
self.layout_manager.terminal_area,
dialog.width(),
dialog.height(),
);
dialog.render(renderer, popup_area);
}
}
AppState::Help => {
let popup_area = PopupLayout::percentage(
self.layout_manager.terminal_area,
80,
80,
);
self.render_help(renderer, popup_area);
}
_ => {}
}
}
fn toggle_help(&mut self) {
self.state = if self.state == AppState::Help {
AppState::Chat
} else {
AppState::Help
};
}
fn toggle_command_palette(&mut self) {
self.state = if self.state == AppState::CommandPalette {
AppState::Chat
} else {
AppState::CommandPalette
};
}
fn open_file_dialog(&mut self) {
}
async fn handle_scroll(&mut self, up: bool) -> Result<()> {
match self.state {
AppState::Chat => {
if up {
self.chat.scroll_up();
} else {
self.chat.scroll_down();
}
}
AppState::FileViewer => {
if up {
self.file_viewer.scroll_up();
} else {
self.file_viewer.scroll_down();
}
}
_ => {}
}
Ok(())
}
async fn handle_page_scroll(&mut self, up: bool) -> Result<()> {
match self.state {
AppState::Chat => {
if up {
self.chat.page_up();
} else {
self.chat.page_down();
}
}
AppState::FileViewer => {
if up {
self.file_viewer.page_up();
} else {
self.file_viewer.page_down();
}
}
_ => {}
}
Ok(())
}
fn is_in_scrollable_area(&self, x: u16, y: u16) -> bool {
let point = ratatui::layout::Rect::new(x, y, 1, 1);
self.layout_manager.main_area.intersects(point) ||
self.layout_manager.side_panel.map_or(false, |area| area.intersects(point))
}
async fn execute_command_palette_result(&mut self, result: String) -> Result<()> {
match result.as_str() {
"open-file" => self.open_file_dialog(),
"toggle-theme" => self.cycle_theme(),
"clear-chat" => self.chat.clear().await?,
_ => {}
}
Ok(())
}
async fn handle_dialog_result(&mut self, result: crate::components::DialogResult) -> Result<()> {
use crate::components::DialogResult;
match result {
DialogResult::Confirmed(_data) => {
}
DialogResult::Cancelled => {
}
}
Ok(())
}
fn update_theme(&mut self) {
let theme = self.theme_manager.current_theme();
self.chat.update_theme(theme);
self.file_viewer.update_theme(theme);
self.status_bar.update_theme(theme);
self.command_palette.update_theme(theme);
}
fn cycle_theme(&mut self) {
let themes = self.theme_manager.available_themes();
if !themes.is_empty() {
let current_name = self.theme_manager.current_theme().name();
let current_index = themes.iter().position(|name| name == current_name).unwrap_or(0);
let next_index = (current_index + 1) % themes.len();
let next_theme = &themes[next_index];
if let Err(e) = self.theme_manager.set_theme(next_theme) {
eprintln!("Failed to set theme {}: {}", next_theme, e);
} else {
self.update_theme();
}
}
}
fn render_help(&self, renderer: &mut Renderer, area: ratatui::layout::Rect) {
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[tokio::test]
async fn test_app_creation() {
let config = Config::default();
let app = App::new(config).await;
assert!(app.is_ok());
}
#[tokio::test]
async fn test_app_state_transitions() {
let config = Config::default();
let mut app = App::new(config).await.unwrap();
assert_eq!(app.state, AppState::Running);
app.toggle_help();
assert_eq!(app.state, AppState::Help);
app.toggle_help();
assert_eq!(app.state, AppState::Chat);
}
}