1use anyhow::Result;
2use crossterm::{
3 event::{self, Event, KeyEventKind},
4 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
5 ExecutableCommand,
6};
7use ratatui::{
8 backend::CrosstermBackend,
9 layout::{Constraint, Direction, Layout, Rect},
10 style::{Color, Modifier, Style},
11 text::{Line, Span},
12 widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table},
13 Frame, Terminal,
14};
15use std::{io, time::Duration};
16
17use crate::app::AppState;
18use crate::app::InputMode;
19use crate::ui::handlers::handle_key_event;
20use crate::utils::cell_reference;
21use crate::utils::index_to_col_name;
22
23pub fn run_app(mut app_state: AppState) -> Result<()> {
24 let mut terminal = setup_terminal()?;
26
27 while !app_state.should_quit {
29 terminal.draw(|f| ui(f, &mut app_state))?;
30
31 if event::poll(Duration::from_millis(50))? {
32 if let Event::Key(key) = event::read()? {
33 if key.kind == KeyEventKind::Press {
34 handle_key_event(&mut app_state, key);
35 }
36 }
37 }
38 }
39
40 restore_terminal(&mut terminal)?;
42
43 Ok(())
44}
45
46fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
48 enable_raw_mode()?;
49 let mut stdout = io::stdout();
50 stdout.execute(EnterAlternateScreen)?;
51
52 let backend = CrosstermBackend::new(stdout);
53 let terminal = Terminal::new(backend)?;
54
55 Ok(terminal)
56}
57
58fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
60 disable_raw_mode()?;
61 terminal.backend_mut().execute(LeaveAlternateScreen)?;
62 terminal.show_cursor()?;
63
64 Ok(())
65}
66
67fn update_visible_area(app_state: &mut AppState, area: Rect) {
69 app_state.visible_rows = (area.height as usize).saturating_sub(3);
71
72 app_state.ensure_column_visible(app_state.selected_cell.1);
74
75 app_state.update_row_number_width();
77
78 let available_width = (area.width as usize).saturating_sub(app_state.row_number_width + 2); let mut visible_cols = 0;
83 let mut width_used = 0;
84
85 for col_idx in app_state.start_col.. {
87 let col_width = app_state.get_column_width(col_idx);
88
89 if col_idx == app_state.start_col {
90 width_used += col_width;
92 visible_cols += 1;
93
94 if width_used >= available_width {
95 break;
96 }
97 } else if width_used + col_width <= available_width {
98 width_used += col_width;
100 visible_cols += 1;
101 } else if width_used < available_width {
102 visible_cols += 1;
104 break;
105 } else {
106 break;
108 }
109 }
110
111 app_state.visible_cols = visible_cols.max(1);
113}
114
115fn ui(f: &mut Frame, app_state: &mut AppState) {
116 let chunks = Layout::default()
118 .direction(Direction::Vertical)
119 .constraints([
120 Constraint::Length(1), Constraint::Min(1), Constraint::Length(app_state.info_panel_height as u16), Constraint::Length(1), ])
125 .split(f.size());
126
127 draw_title_with_tabs(f, app_state, chunks[0]);
128
129 update_visible_area(app_state, chunks[1]);
130 draw_spreadsheet(f, app_state, chunks[1]);
131
132 draw_info_panel(f, app_state, chunks[2]);
133 draw_status_bar(f, app_state, chunks[3]);
134
135 if let InputMode::Help = app_state.input_mode {
137 draw_help_popup(f, app_state, f.size());
138 }
139
140 match app_state.input_mode {
142 InputMode::LazyLoading | InputMode::CommandInLazyLoading => {
143 let current_index = app_state.workbook.get_current_sheet_index();
144 if !app_state.workbook.is_sheet_loaded(current_index) {
145 draw_lazy_loading_overlay(f, app_state, chunks[1]);
146 } else if matches!(app_state.input_mode, InputMode::LazyLoading) {
147 app_state.input_mode = crate::app::InputMode::Normal;
149 }
150 }
151 _ => {}
152 }
153}
154
155fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) {
156 let start_row = app_state.start_row;
158 let end_row = start_row + app_state.visible_rows - 1;
159 let start_col = app_state.start_col;
160 let end_col = start_col + app_state.visible_cols - 1;
161
162 let mut constraints = Vec::with_capacity(app_state.visible_cols + 1);
163 constraints.push(Constraint::Length(app_state.row_number_width as u16)); for col in start_col..=end_col {
166 constraints.push(Constraint::Length(app_state.get_column_width(col) as u16));
167 }
168
169 let (table_block, header_style, cell_style) =
171 if matches!(app_state.input_mode, InputMode::Normal) {
172 (
174 Block::default()
175 .borders(Borders::ALL)
176 .border_style(Style::default().fg(Color::LightCyan)),
177 Style::default().bg(Color::DarkGray).fg(Color::Gray),
178 Style::default(),
179 )
180 } else {
181 (
183 Block::default().borders(Borders::ALL),
184 Style::default().fg(Color::DarkGray),
185 Style::default().fg(Color::DarkGray), )
187 };
188
189 let mut header_cells = Vec::with_capacity(app_state.visible_cols + 1);
191 header_cells.push(Cell::from("").style(header_style));
192
193 for col in start_col..=end_col {
195 let col_name = index_to_col_name(col);
196 header_cells.push(Cell::from(col_name).style(header_style));
197 }
198
199 let header = Row::new(header_cells).height(1);
200
201 let rows = (start_row..=end_row).map(|row| {
203 let mut cells = Vec::with_capacity(app_state.visible_cols + 1);
204
205 cells.push(Cell::from(row.to_string()).style(header_style));
207
208 for col in start_col..=end_col {
210 let content = if app_state.selected_cell == (row, col)
211 && matches!(app_state.input_mode, InputMode::Editing)
212 {
213 let current_content = app_state.text_area.lines().join("\n");
215 let col_width = app_state.get_column_width(col);
216
217 let display_width = current_content
219 .chars()
220 .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 });
221
222 if display_width > col_width.saturating_sub(2) {
223 let mut result = String::with_capacity(col_width);
225 let mut cumulative_width = 0;
226
227 for c in current_content.chars().rev().take(col_width * 2) {
229 let char_width = if c.is_ascii() { 1 } else { 2 };
230 if cumulative_width + char_width <= col_width.saturating_sub(2) {
231 cumulative_width += char_width;
232 result.push(c);
233 } else {
234 break;
235 }
236 }
237
238 result.chars().rev().collect::<String>()
240 } else {
241 current_content
242 }
243 } else {
244 let content = app_state.get_cell_content(row, col);
246 let col_width = app_state.get_column_width(col);
247
248 let display_width = content
250 .chars()
251 .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 });
252
253 if display_width > col_width {
254 let mut result = String::with_capacity(col_width);
256 let mut current_width = 0;
257
258 for c in content.chars() {
259 let char_width = if c.is_ascii() { 1 } else { 2 };
260 if current_width + char_width < col_width {
261 result.push(c);
262 current_width += char_width;
263 } else {
264 break;
265 }
266 }
267
268 if !content.is_empty() && result.len() < content.len() {
269 result.push('…');
270 }
271
272 result
273 } else {
274 content
275 }
276 };
277
278 let style = if app_state.selected_cell == (row, col) {
280 Style::default().bg(Color::White).fg(Color::Black)
281 } else if app_state.highlight_enabled && app_state.search_results.contains(&(row, col))
282 {
283 Style::default().bg(Color::Yellow).fg(Color::Black)
284 } else {
285 Style::default()
286 };
287
288 cells.push(Cell::from(content).style(style));
289 }
290
291 Row::new(cells)
292 });
293
294 let table = Table::new(
296 std::iter::once(header).chain(rows),
298 )
299 .block(table_block)
300 .style(cell_style)
301 .widths(&constraints);
302
303 f.render_widget(table, area);
304}
305
306fn parse_command(input: &str) -> Vec<Span> {
308 if input.is_empty() {
309 return vec![Span::raw("")];
310 }
311
312 let known_commands = [
313 "w",
314 "wq",
315 "q",
316 "q!",
317 "x",
318 "y",
319 "d",
320 "put",
321 "pu",
322 "nohlsearch",
323 "noh",
324 "help",
325 "delsheet",
326 ];
327
328 let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc"];
329
330 let special_keywords = ["fit", "min", "all", "h", "v", "horizontal", "vertical"];
331
332 if known_commands.contains(&input) {
334 return vec![Span::styled(input, Style::default().fg(Color::Yellow))];
335 }
336
337 let parts: Vec<&str> = input.split_whitespace().collect();
339 if parts.is_empty() {
340 return vec![Span::raw(input)];
341 }
342
343 let cmd = parts[0];
344
345 if commands_with_params.contains(&cmd) || (cmd.starts_with("ej") && cmd.len() <= 3) {
347 let mut spans = Vec::new();
348
349 spans.push(Span::styled(cmd, Style::default().fg(Color::Yellow)));
351
352 if parts.len() > 1 {
354 spans.push(Span::raw(" "));
355
356 for i in 1..parts.len() {
357 let style = if special_keywords.contains(&parts[i]) {
359 Style::default().fg(Color::Yellow) } else {
361 Style::default().fg(Color::LightCyan) };
363
364 spans.push(Span::styled(parts[i], style));
365
366 if i < parts.len() - 1 {
368 spans.push(Span::raw(" "));
369 }
370 }
371 }
372
373 return spans;
374 }
375
376 vec![Span::raw(input)]
378}
379
380fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) {
381 let chunks = Layout::default()
382 .direction(Direction::Vertical)
383 .constraints([
384 Constraint::Percentage(50), Constraint::Percentage(50), ])
387 .split(area);
388
389 let (row, col) = app_state.selected_cell;
391 let cell_ref = cell_reference(app_state.selected_cell);
392
393 if let InputMode::Editing = app_state.input_mode {
395 let (vim_mode_str, mode_color) = if let Some(vim_state) = &app_state.vim_state {
396 match vim_state.mode {
397 crate::app::VimMode::Normal => ("NORMAL", Color::Green),
398 crate::app::VimMode::Insert => ("INSERT", Color::LightBlue),
399 crate::app::VimMode::Visual => ("VISUAL", Color::Yellow),
400 crate::app::VimMode::Operator(op) => {
401 let op_str = match op {
402 'y' => "YANK",
403 'd' => "DELETE",
404 'c' => "CHANGE",
405 _ => "OPERATOR",
406 };
407 (op_str, Color::LightRed)
408 }
409 }
410 } else {
411 ("VIM", Color::White)
412 };
413
414 let title = Line::from(vec![
415 Span::raw(" Editing Cell "),
416 Span::raw(cell_ref.clone()),
417 Span::raw(" - "),
418 Span::styled(
419 vim_mode_str,
420 Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
421 ),
422 Span::raw(" "),
423 ]);
424
425 let edit_block = Block::default()
426 .borders(Borders::ALL)
427 .border_style(Style::default().fg(Color::LightCyan))
428 .title(title);
429
430 let inner_area = edit_block.inner(chunks[0]);
432 let padded_area = Rect {
433 x: inner_area.x + 1, y: inner_area.y,
435 width: inner_area.width.saturating_sub(2), height: inner_area.height,
437 };
438
439 f.render_widget(edit_block, chunks[0]);
440 f.render_widget(app_state.text_area.widget(), padded_area);
441 } else {
442 let content = app_state.get_cell_content(row, col);
444
445 let title = format!(" Cell {cell_ref} Content ");
446 let cell_block = Block::default().borders(Borders::ALL).title(title);
447
448 let cell_paragraph = Paragraph::new(content)
450 .block(cell_block)
451 .wrap(ratatui::widgets::Wrap { trim: false });
452
453 f.render_widget(cell_paragraph, chunks[0]);
454 }
455
456 let notification_block = if matches!(app_state.input_mode, InputMode::Editing) {
458 Block::default()
459 .borders(Borders::ALL)
460 .border_style(Style::default().fg(Color::DarkGray))
461 .title(Span::styled(
462 " Notifications ",
463 Style::default().fg(Color::DarkGray),
464 ))
465 } else {
466 Block::default()
467 .borders(Borders::ALL)
468 .title(" Notifications ")
469 };
470
471 let notification_height = notification_block.inner(chunks[1]).height as usize;
473
474 let notifications_text = if app_state.notification_messages.is_empty() {
476 String::new()
477 } else if app_state.notification_messages.len() <= notification_height {
478 app_state.notification_messages.join("\n")
479 } else {
480 let start_idx = app_state.notification_messages.len() - notification_height;
482 app_state.notification_messages[start_idx..].join("\n")
483 };
484
485 let notification_paragraph = Paragraph::new(notifications_text)
486 .block(notification_block)
487 .wrap(ratatui::widgets::Wrap { trim: false })
488 .style(if matches!(app_state.input_mode, InputMode::Editing) {
489 Style::default().fg(Color::DarkGray)
490 } else {
491 Style::default()
492 });
493
494 f.render_widget(notification_paragraph, chunks[1]);
495}
496
497fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) {
498 match app_state.input_mode {
499 InputMode::Normal => {
500 let status = "Input :help for operating instructions | hjkl=move [ ]=prev/next-sheet Enter=edit y=copy d=cut p=paste /=search N/n=prev/next-search-result :=command ";
501
502 let status_widget = Paragraph::new(status)
503 .style(Style::default())
504 .alignment(ratatui::layout::Alignment::Left);
505
506 f.render_widget(status_widget, area);
507 }
508
509 InputMode::Editing => {
510 let status_widget = Paragraph::new("Press Esc to exit editing mode")
511 .style(Style::default().fg(Color::DarkGray))
512 .alignment(ratatui::layout::Alignment::Left);
513
514 f.render_widget(status_widget, area);
515 }
516
517 InputMode::Command | InputMode::CommandInLazyLoading => {
518 let mut spans = vec![Span::styled(":", Style::default())];
520 let command_spans = parse_command(&app_state.input_buffer);
521 spans.extend(command_spans);
522
523 let text = Line::from(spans);
524 let status_widget = Paragraph::new(text)
525 .style(Style::default())
526 .alignment(ratatui::layout::Alignment::Left);
527
528 f.render_widget(status_widget, area);
529 }
530
531 InputMode::SearchForward | InputMode::SearchBackward => {
532 let prefix = if matches!(app_state.input_mode, InputMode::SearchForward) {
534 "/"
535 } else {
536 "?"
537 };
538
539 let chunks = Layout::default()
541 .direction(Direction::Horizontal)
542 .constraints([
543 Constraint::Length(1), Constraint::Min(1), ])
546 .split(area);
547
548 let prefix_widget = Paragraph::new(prefix)
550 .style(Style::default())
551 .alignment(ratatui::layout::Alignment::Left);
552
553 f.render_widget(prefix_widget, chunks[0]);
554
555 let mut text_area = app_state.text_area.clone();
557 text_area.set_cursor_line_style(Style::default());
558 text_area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
559
560 f.render_widget(text_area.widget(), chunks[1]);
561 }
562
563 InputMode::Help => {
564 }
566
567 InputMode::LazyLoading => {
568 let status_widget = Paragraph::new(
570 "Sheet data not loaded... Press Enter to load, [ and ] to switch sheets, :delsheet to delete current sheet, :q to quit, :q! to quit without saving",
571 )
572 .style(Style::default().fg(Color::LightYellow))
573 .alignment(ratatui::layout::Alignment::Left);
574
575 f.render_widget(status_widget, area);
576 }
577 }
578}
579
580fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) {
581 let overlay = Block::default()
583 .style(Style::default().bg(Color::Black).fg(Color::White))
584 .borders(Borders::ALL)
585 .border_style(Style::default().fg(Color::LightCyan));
586
587 f.render_widget(Clear, area);
588 f.render_widget(overlay, area);
589
590 let message = "Press Enter to load the sheet, [ and ] to switch sheets";
592 let width = message.len() as u16;
593 let x = area.x + (area.width.saturating_sub(width)) / 2;
594 let y = area.y + area.height / 2;
595
596 if x < area.width && y < area.height {
597 let message_area = Rect {
598 x,
599 y,
600 width: width.min(area.width),
601 height: 1,
602 };
603
604 let message_widget = Paragraph::new(message).style(
605 Style::default()
606 .fg(Color::LightYellow)
607 .add_modifier(Modifier::BOLD),
608 );
609
610 f.render_widget(message_widget, message_area);
611 }
612}
613
614fn draw_help_popup(f: &mut Frame, app_state: &mut AppState, area: Rect) {
615 f.render_widget(Clear, area);
617
618 let line_count = app_state.help_text.lines().count() as u16;
620 let content_height = line_count + 2; let max_line_width = app_state
623 .help_text
624 .lines()
625 .map(|line| line.len() as u16)
626 .max()
627 .unwrap_or(40);
628
629 let content_width = max_line_width + 4; let popup_width = content_width.min(area.width.saturating_sub(4));
633 let popup_height = content_height.min(area.height.saturating_sub(4));
634
635 let popup_x = (area.width.saturating_sub(popup_width)) / 2;
637 let popup_y = (area.height.saturating_sub(popup_height)) / 2;
638
639 let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
640
641 let visible_lines = popup_height.saturating_sub(2) as usize; app_state.help_visible_lines = visible_lines;
644
645 let line_count = app_state.help_text.lines().count();
646 let max_scroll = line_count.saturating_sub(visible_lines).max(0);
647
648 app_state.help_scroll = app_state.help_scroll.min(max_scroll);
649
650 let mut title = " [ESC/Enter to close] ".to_string();
651
652 if max_scroll > 0 {
653 let scroll_indicator = if app_state.help_scroll == 0 {
654 " [↓ or j to scroll] "
655 } else if app_state.help_scroll >= max_scroll {
656 " [↑ or k to scroll] "
657 } else {
658 " [↑↓ or j/k to scroll] "
659 };
660 title.push_str(scroll_indicator);
661 }
662
663 let help_block = Block::default()
664 .title(title)
665 .title_style(
666 Style::default()
667 .fg(Color::Yellow)
668 .add_modifier(Modifier::BOLD),
669 )
670 .borders(Borders::ALL)
671 .border_style(Style::default().fg(Color::LightCyan))
672 .style(Style::default().bg(Color::Blue).fg(Color::White));
673
674 let help_paragraph = Paragraph::new(app_state.help_text.clone())
676 .block(help_block)
677 .wrap(ratatui::widgets::Wrap { trim: false })
678 .scroll((app_state.help_scroll as u16, 0));
679
680 f.render_widget(help_paragraph, popup_area);
681}
682
683fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) {
684 let is_editing = matches!(app_state.input_mode, InputMode::Editing);
685 let sheet_names = app_state.workbook.get_sheet_names();
686 let current_index = app_state.workbook.get_current_sheet_index();
687
688 let file_name = app_state
689 .file_path
690 .file_name()
691 .and_then(|n| n.to_str())
692 .unwrap_or("Untitled");
693
694 let title_content = format!(" {file_name} ");
695
696 let title_width = title_content
697 .chars()
698 .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }) as u16;
699
700 let available_width = area.width.saturating_sub(title_width) as usize;
701
702 let mut tab_widths = Vec::new();
703 let mut total_width = 0;
704 let mut visible_tabs = Vec::new();
705
706 for (i, name) in sheet_names.iter().enumerate() {
707 let tab_width = name.len();
708
709 if total_width + tab_width <= available_width {
710 tab_widths.push(tab_width as u16);
711 total_width += tab_width;
712 visible_tabs.push(i);
713 } else {
714 if !visible_tabs.contains(¤t_index) {
716 while !visible_tabs.is_empty() && total_width + tab_width > available_width {
718 let removed_width = tab_widths.remove(0) as usize;
719 visible_tabs.remove(0);
720 total_width -= removed_width;
721 }
722
723 if total_width + tab_width <= available_width {
725 tab_widths.push(tab_width as u16);
726 visible_tabs.push(current_index);
727 }
728 }
729 break;
730 }
731 }
732
733 let max_title_width = (area.width * 2 / 3).min(title_width);
735
736 let horizontal_layout = Layout::default()
738 .direction(Direction::Horizontal)
739 .constraints([Constraint::Length(max_title_width), Constraint::Min(0)])
740 .split(area);
741
742 let title_style = if is_editing {
743 Style::default().bg(Color::DarkGray).fg(Color::Gray)
744 } else {
745 Style::default().bg(Color::DarkGray).fg(Color::White)
746 };
747
748 let title_widget = Paragraph::new(title_content).style(title_style);
749
750 f.render_widget(title_widget, horizontal_layout[0]);
751
752 let mut tab_constraints = Vec::new();
754 for &width in &tab_widths {
755 tab_constraints.push(Constraint::Length(width));
756 }
757 tab_constraints.push(Constraint::Min(0)); let tab_layout = Layout::default()
760 .direction(Direction::Horizontal)
761 .constraints(tab_constraints)
762 .split(horizontal_layout[1]);
763
764 for (layout_idx, &sheet_idx) in visible_tabs.iter().enumerate() {
766 if layout_idx >= tab_layout.len() - 1 {
767 break;
768 }
769
770 let name = &sheet_names[sheet_idx];
771 let is_current = sheet_idx == current_index;
772
773 let style = if is_editing {
774 if is_current {
775 Style::default().bg(Color::DarkGray).fg(Color::Gray)
776 } else {
777 Style::default().fg(Color::DarkGray)
778 }
779 } else if is_current {
780 Style::default().bg(Color::DarkGray).fg(Color::White)
781 } else {
782 Style::default()
783 };
784
785 let tab_widget = Paragraph::new(name.to_string())
786 .style(style)
787 .alignment(ratatui::layout::Alignment::Center);
788
789 f.render_widget(tab_widget, tab_layout[layout_idx]);
790 }
791
792 if visible_tabs.len() < sheet_names.len() {
794 let more_indicator = "...";
795 let indicator_style = Style::default().bg(Color::DarkGray).fg(Color::White);
796 let indicator_width = more_indicator.len() as u16;
797
798 let indicator_rect = Rect {
800 x: area.x + area.width - indicator_width,
801 y: area.y,
802 width: indicator_width,
803 height: 1,
804 };
805
806 let indicator_widget = Paragraph::new(more_indicator).style(indicator_style);
807 f.render_widget(indicator_widget, indicator_rect);
808 }
809}