1use anyhow::Result;
2use ratatui::{backend::CrosstermBackend, Terminal};
3use std::io;
4use tokio::time::{Duration, Instant};
5
6use crate::{
7 chat::ChatComponent,
8 components::{CommandPalette, Dialog, StatusBar},
9 config::Config,
10 events::{AppEvent, EventHandler, InputEvent, KeybindHandler, MouseHandler},
11 file_viewer::FileViewer,
12 layout::{LayoutManager, PopupLayout},
13 renderer::Renderer,
14 theme::ThemeManager,
15};
16
17pub struct App {
19 config: Config,
21 theme_manager: ThemeManager,
23 event_handler: EventHandler,
25 keybind_handler: KeybindHandler,
27 mouse_handler: MouseHandler,
29 layout_manager: LayoutManager,
31 chat: ChatComponent,
33 file_viewer: FileViewer,
35 status_bar: StatusBar,
37 command_palette: CommandPalette,
39 active_dialog: Option<Dialog>,
41 state: AppState,
43 last_render: Instant,
45 target_fps: u64,
47}
48
49#[derive(Debug, Clone, PartialEq)]
51pub enum AppState {
52 Running,
54 Quitting,
56 Help,
58 CommandPalette,
60 Dialog,
62 FileViewer,
64 Chat,
66}
67
68impl App {
69 pub async fn new(config: Config) -> Result<Self> {
71 let theme_manager = ThemeManager::default();
72 let event_handler = EventHandler::new();
73 let mut keybind_handler = KeybindHandler::new();
74
75 keybind_handler.set_leader_key(config.keybinds.leader.clone());
77 for (action, key) in &config.keybinds.bindings {
78 keybind_handler.bind(key.clone(), action.clone());
79 }
80
81 let mouse_handler = MouseHandler::new();
82
83 let layout_manager = LayoutManager::new(ratatui::layout::Rect::new(0, 0, 80, 24));
85
86 let chat = ChatComponent::new(&config.chat, theme_manager.current_theme());
88 let file_viewer = FileViewer::new(&config.file_viewer, theme_manager.current_theme());
89 let status_bar = StatusBar::new(theme_manager.current_theme());
90 let command_palette = CommandPalette::new(theme_manager.current_theme());
91
92 Ok(Self {
93 config,
94 theme_manager,
95 event_handler,
96 keybind_handler,
97 mouse_handler,
98 layout_manager,
99 chat,
100 file_viewer,
101 status_bar,
102 command_palette,
103 active_dialog: None,
104 state: AppState::Running,
105 last_render: Instant::now(),
106 target_fps: 60,
107 })
108 }
109
110 pub async fn run(&mut self) -> Result<()> {
112 let mut terminal = crate::init_terminal()?;
113
114 while self.state != AppState::Quitting {
116 if let Some(event) = self.event_handler.try_next() {
118 self.handle_event(event).await?;
119 }
120
121 let now = Instant::now();
123 let frame_duration = Duration::from_millis(1000 / self.target_fps);
124
125 if now.duration_since(self.last_render) >= frame_duration {
126 self.render(&mut terminal)?;
127 self.last_render = now;
128 }
129
130 tokio::time::sleep(Duration::from_millis(1)).await;
132 }
133
134 crate::restore_terminal(&mut terminal)?;
135 Ok(())
136 }
137
138 async fn handle_event(&mut self, event: AppEvent) -> Result<()> {
140 match event {
141 AppEvent::Input(input_event) => {
142 self.handle_input_event(input_event).await?;
143 }
144 AppEvent::Resize(width, height) => {
145 let new_area = ratatui::layout::Rect::new(0, 0, width, height);
146 self.layout_manager.resize(new_area);
147 }
148 AppEvent::Quit => {
149 self.state = AppState::Quitting;
150 }
151 AppEvent::Tick => {
152 self.update_components().await?;
154 }
155 AppEvent::Custom(message) => {
156 self.handle_custom_event(message).await?;
157 }
158 }
159 Ok(())
160 }
161
162 async fn handle_input_event(&mut self, event: InputEvent) -> Result<()> {
164 match event {
165 InputEvent::Key(key_event) => {
166 if let Some(action) = self.keybind_handler.handle_key(&key_event) {
168 self.execute_action(&action).await?;
169 } else {
170 self.route_key_event(key_event).await?;
172 }
173 }
174 InputEvent::Mouse(mouse_event) => {
175 let action = self.mouse_handler.handle_mouse(&mouse_event);
176 self.handle_mouse_action(action).await?;
177 }
178 InputEvent::Paste(data) => {
179 if self.state == AppState::Chat {
181 self.chat.handle_paste(data).await?;
182 }
183 }
184 InputEvent::FocusGained | InputEvent::FocusLost => {
185 }
187 }
188 Ok(())
189 }
190
191 async fn execute_action(&mut self, action: &str) -> Result<()> {
193 match action {
194 "quit" => {
195 self.state = AppState::Quitting;
196 }
197 "help" => {
198 self.toggle_help();
199 }
200 "command_palette" => {
201 self.toggle_command_palette();
202 }
203 "send_message" => {
204 if self.state == AppState::Chat {
205 self.chat.send_message().await?;
206 }
207 }
208 "new_line" => {
209 if self.state == AppState::Chat {
210 self.chat.insert_newline();
211 }
212 }
213 "clear_input" => {
214 if self.state == AppState::Chat {
215 self.chat.clear_input();
216 }
217 }
218 "open_file" => {
219 self.open_file_dialog();
220 }
221 "close_file" => {
222 self.file_viewer.close_file();
223 if self.state == AppState::FileViewer {
224 self.state = AppState::Chat;
225 }
226 }
227 "toggle_diff" => {
228 self.file_viewer.toggle_diff_style();
229 }
230 "scroll_up" => {
231 self.handle_scroll(true).await?;
232 }
233 "scroll_down" => {
234 self.handle_scroll(false).await?;
235 }
236 "page_up" => {
237 self.handle_page_scroll(true).await?;
238 }
239 "page_down" => {
240 self.handle_page_scroll(false).await?;
241 }
242 _ => {
243 }
245 }
246 Ok(())
247 }
248
249 async fn route_key_event(&mut self, key_event: crossterm::event::KeyEvent) -> Result<()> {
251 match self.state {
252 AppState::Chat => {
253 self.chat.handle_key_event(key_event).await?;
254 }
255 AppState::FileViewer => {
256 self.file_viewer.handle_key_event(key_event).await?;
257 }
258 AppState::CommandPalette => {
259 if let Some(result) = self.command_palette.handle_key_event(key_event).await? {
260 self.execute_command_palette_result(result).await?;
261 self.state = AppState::Chat;
262 }
263 }
264 AppState::Dialog => {
265 if let Some(ref mut dialog) = self.active_dialog {
266 if let Some(result) = dialog.handle_key_event(key_event).await? {
267 self.handle_dialog_result(result).await?;
268 self.active_dialog = None;
269 self.state = AppState::Chat;
270 }
271 }
272 }
273 _ => {}
274 }
275 Ok(())
276 }
277
278 async fn handle_mouse_action(&mut self, action: crate::events::MouseAction) -> Result<()> {
280 use crate::events::MouseAction;
281
282 match action {
283 MouseAction::LeftClick(x, y) => {
284 if self.layout_manager.main_area.intersects(ratatui::layout::Rect::new(x, y, 1, 1)) {
286 if self.file_viewer.is_visible() {
287 self.state = AppState::FileViewer;
288 } else {
289 self.state = AppState::Chat;
290 }
291 }
292 }
293 MouseAction::ScrollUp(x, y) => {
294 if self.is_in_scrollable_area(x, y) {
295 self.handle_scroll(true).await?;
296 }
297 }
298 MouseAction::ScrollDown(x, y) => {
299 if self.is_in_scrollable_area(x, y) {
300 self.handle_scroll(false).await?;
301 }
302 }
303 _ => {}
304 }
305 Ok(())
306 }
307
308 async fn update_components(&mut self) -> Result<()> {
310 self.chat.update().await?;
311 self.file_viewer.update().await?;
312 self.status_bar.update(&self.state).await?;
313 Ok(())
314 }
315
316 async fn handle_custom_event(&mut self, message: String) -> Result<()> {
318 match message.as_str() {
321 "theme_changed" => {
322 self.update_theme();
323 }
324 "file_opened" => {
325 self.state = AppState::FileViewer;
326 }
327 _ => {}
328 }
329 Ok(())
330 }
331
332 fn render(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
334 terminal.draw(|frame| {
335 let mut renderer = Renderer::new(frame, self.theme_manager.current_theme());
336
337 self.render_main_layout(&mut renderer);
339
340 self.render_overlays(&mut renderer);
342 })?;
343 Ok(())
344 }
345
346 fn render_main_layout(&mut self, renderer: &mut Renderer) {
348 self.status_bar.render(renderer, self.layout_manager.status_area);
350
351 if self.file_viewer.is_visible() {
353 if let Some(side_panel) = self.layout_manager.side_panel {
355 self.chat.render(renderer, self.layout_manager.main_area);
356 self.file_viewer.render(renderer, side_panel);
357 } else {
358 self.file_viewer.render(renderer, self.layout_manager.main_area);
359 }
360 } else {
361 self.chat.render(renderer, self.layout_manager.main_area);
362 }
363
364 self.chat.render_input(renderer, self.layout_manager.input_area);
366 }
367
368 fn render_overlays(&mut self, renderer: &mut Renderer) {
370 match self.state {
371 AppState::CommandPalette => {
372 let popup_area = PopupLayout::centered(
373 self.layout_manager.terminal_area,
374 60,
375 15,
376 );
377 self.command_palette.render(renderer, popup_area);
378 }
379 AppState::Dialog => {
380 if let Some(ref mut dialog) = self.active_dialog {
381 let popup_area = PopupLayout::centered(
382 self.layout_manager.terminal_area,
383 dialog.width(),
384 dialog.height(),
385 );
386 dialog.render(renderer, popup_area);
387 }
388 }
389 AppState::Help => {
390 let popup_area = PopupLayout::percentage(
391 self.layout_manager.terminal_area,
392 80,
393 80,
394 );
395 self.render_help(renderer, popup_area);
396 }
397 _ => {}
398 }
399 }
400
401 fn toggle_help(&mut self) {
403 self.state = if self.state == AppState::Help {
404 AppState::Chat
405 } else {
406 AppState::Help
407 };
408 }
409
410 fn toggle_command_palette(&mut self) {
412 self.state = if self.state == AppState::CommandPalette {
413 AppState::Chat
414 } else {
415 AppState::CommandPalette
416 };
417 }
418
419 fn open_file_dialog(&mut self) {
421 }
424
425 async fn handle_scroll(&mut self, up: bool) -> Result<()> {
427 match self.state {
428 AppState::Chat => {
429 if up {
430 self.chat.scroll_up();
431 } else {
432 self.chat.scroll_down();
433 }
434 }
435 AppState::FileViewer => {
436 if up {
437 self.file_viewer.scroll_up();
438 } else {
439 self.file_viewer.scroll_down();
440 }
441 }
442 _ => {}
443 }
444 Ok(())
445 }
446
447 async fn handle_page_scroll(&mut self, up: bool) -> Result<()> {
449 match self.state {
450 AppState::Chat => {
451 if up {
452 self.chat.page_up();
453 } else {
454 self.chat.page_down();
455 }
456 }
457 AppState::FileViewer => {
458 if up {
459 self.file_viewer.page_up();
460 } else {
461 self.file_viewer.page_down();
462 }
463 }
464 _ => {}
465 }
466 Ok(())
467 }
468
469 fn is_in_scrollable_area(&self, x: u16, y: u16) -> bool {
471 let point = ratatui::layout::Rect::new(x, y, 1, 1);
472 self.layout_manager.main_area.intersects(point) ||
473 self.layout_manager.side_panel.map_or(false, |area| area.intersects(point))
474 }
475
476 async fn execute_command_palette_result(&mut self, result: String) -> Result<()> {
478 match result.as_str() {
480 "open-file" => self.open_file_dialog(),
481 "toggle-theme" => self.cycle_theme(),
482 "clear-chat" => self.chat.clear().await?,
483 _ => {}
484 }
485 Ok(())
486 }
487
488 async fn handle_dialog_result(&mut self, result: crate::components::DialogResult) -> Result<()> {
490 use crate::components::DialogResult;
491
492 match result {
493 DialogResult::Confirmed(_data) => {
494 }
496 DialogResult::Cancelled => {
497 }
499 }
500 Ok(())
501 }
502
503 fn update_theme(&mut self) {
505 let theme = self.theme_manager.current_theme();
506 self.chat.update_theme(theme);
507 self.file_viewer.update_theme(theme);
508 self.status_bar.update_theme(theme);
509 self.command_palette.update_theme(theme);
510 }
511
512 fn cycle_theme(&mut self) {
514 let themes = self.theme_manager.available_themes();
515 if !themes.is_empty() {
516 let current_name = self.theme_manager.current_theme().name();
517 let current_index = themes.iter().position(|name| name == current_name).unwrap_or(0);
518 let next_index = (current_index + 1) % themes.len();
519 let next_theme = &themes[next_index];
520
521 if let Err(e) = self.theme_manager.set_theme(next_theme) {
522 eprintln!("Failed to set theme {}: {}", next_theme, e);
523 } else {
524 self.update_theme();
525 }
526 }
527 }
528
529 fn render_help(&self, renderer: &mut Renderer, area: ratatui::layout::Rect) {
531 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use crate::config::Config;
540
541 #[tokio::test]
542 async fn test_app_creation() {
543 let config = Config::default();
544 let app = App::new(config).await;
545 assert!(app.is_ok());
546 }
547
548 #[tokio::test]
549 async fn test_app_state_transitions() {
550 let config = Config::default();
551 let mut app = App::new(config).await.unwrap();
552
553 assert_eq!(app.state, AppState::Running);
554
555 app.toggle_help();
556 assert_eq!(app.state, AppState::Help);
557
558 app.toggle_help();
559 assert_eq!(app.state, AppState::Chat);
560 }
561}