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 "addsheet",
326 "delsheet",
327 ];
328
329 let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc", "addsheet"];
330
331 let special_keywords = ["fit", "min", "all", "h", "v", "horizontal", "vertical"];
332
333 if known_commands.contains(&input) {
335 return vec![Span::styled(input, Style::default().fg(Color::Yellow))];
336 }
337
338 let parts: Vec<&str> = input.split_whitespace().collect();
340 if parts.is_empty() {
341 return vec![Span::raw(input)];
342 }
343
344 let cmd = parts[0];
345
346 if commands_with_params.contains(&cmd) || (cmd.starts_with("ej") && cmd.len() <= 3) {
348 let mut spans = Vec::new();
349
350 spans.push(Span::styled(cmd, Style::default().fg(Color::Yellow)));
352
353 if parts.len() > 1 {
355 spans.push(Span::raw(" "));
356
357 for i in 1..parts.len() {
358 let style = if special_keywords.contains(&parts[i]) {
360 Style::default().fg(Color::Yellow) } else {
362 Style::default().fg(Color::LightCyan) };
364
365 spans.push(Span::styled(parts[i], style));
366
367 if i < parts.len() - 1 {
369 spans.push(Span::raw(" "));
370 }
371 }
372 }
373
374 return spans;
375 }
376
377 vec![Span::raw(input)]
379}
380
381fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) {
382 let chunks = Layout::default()
383 .direction(Direction::Vertical)
384 .constraints([
385 Constraint::Percentage(50), Constraint::Percentage(50), ])
388 .split(area);
389
390 let (row, col) = app_state.selected_cell;
392 let cell_ref = cell_reference(app_state.selected_cell);
393
394 if let InputMode::Editing = app_state.input_mode {
396 let (vim_mode_str, mode_color) = if let Some(vim_state) = &app_state.vim_state {
397 match vim_state.mode {
398 crate::app::VimMode::Normal => ("NORMAL", Color::Green),
399 crate::app::VimMode::Insert => ("INSERT", Color::LightBlue),
400 crate::app::VimMode::Visual => ("VISUAL", Color::Yellow),
401 crate::app::VimMode::Operator(op) => {
402 let op_str = match op {
403 'y' => "YANK",
404 'd' => "DELETE",
405 'c' => "CHANGE",
406 _ => "OPERATOR",
407 };
408 (op_str, Color::LightRed)
409 }
410 }
411 } else {
412 ("VIM", Color::White)
413 };
414
415 let title = Line::from(vec![
416 Span::raw(" Editing Cell "),
417 Span::raw(cell_ref.clone()),
418 Span::raw(" - "),
419 Span::styled(
420 vim_mode_str,
421 Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
422 ),
423 Span::raw(" "),
424 ]);
425
426 let edit_block = Block::default()
427 .borders(Borders::ALL)
428 .border_style(Style::default().fg(Color::LightCyan))
429 .title(title);
430
431 let inner_area = edit_block.inner(chunks[0]);
433 let padded_area = Rect {
434 x: inner_area.x + 1, y: inner_area.y,
436 width: inner_area.width.saturating_sub(2), height: inner_area.height,
438 };
439
440 f.render_widget(edit_block, chunks[0]);
441 f.render_widget(app_state.text_area.widget(), padded_area);
442 } else {
443 let content = app_state.get_cell_content(row, col);
445
446 let title = format!(" Cell {cell_ref} Content ");
447 let cell_block = Block::default().borders(Borders::ALL).title(title);
448
449 let cell_paragraph = Paragraph::new(content)
451 .block(cell_block)
452 .wrap(ratatui::widgets::Wrap { trim: false });
453
454 f.render_widget(cell_paragraph, chunks[0]);
455 }
456
457 let notification_block = if matches!(app_state.input_mode, InputMode::Editing) {
459 Block::default()
460 .borders(Borders::ALL)
461 .border_style(Style::default().fg(Color::DarkGray))
462 .title(Span::styled(
463 " Notifications ",
464 Style::default().fg(Color::DarkGray),
465 ))
466 } else {
467 Block::default()
468 .borders(Borders::ALL)
469 .title(" Notifications ")
470 };
471
472 let notification_height = notification_block.inner(chunks[1]).height as usize;
474
475 let notifications_text = if app_state.notification_messages.is_empty() {
477 String::new()
478 } else if app_state.notification_messages.len() <= notification_height {
479 app_state.notification_messages.join("\n")
480 } else {
481 let start_idx = app_state.notification_messages.len() - notification_height;
483 app_state.notification_messages[start_idx..].join("\n")
484 };
485
486 let notification_paragraph = Paragraph::new(notifications_text)
487 .block(notification_block)
488 .wrap(ratatui::widgets::Wrap { trim: false })
489 .style(if matches!(app_state.input_mode, InputMode::Editing) {
490 Style::default().fg(Color::DarkGray)
491 } else {
492 Style::default()
493 });
494
495 f.render_widget(notification_paragraph, chunks[1]);
496}
497
498fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) {
499 match app_state.input_mode {
500 InputMode::Normal => {
501 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 ";
502
503 let status_widget = Paragraph::new(status)
504 .style(Style::default())
505 .alignment(ratatui::layout::Alignment::Left);
506
507 f.render_widget(status_widget, area);
508 }
509
510 InputMode::Editing => {
511 let status_widget = Paragraph::new("Press Esc to exit editing mode")
512 .style(Style::default().fg(Color::DarkGray))
513 .alignment(ratatui::layout::Alignment::Left);
514
515 f.render_widget(status_widget, area);
516 }
517
518 InputMode::Command | InputMode::CommandInLazyLoading => {
519 let mut spans = vec![Span::styled(":", Style::default())];
521 let command_spans = parse_command(&app_state.input_buffer);
522 spans.extend(command_spans);
523
524 let text = Line::from(spans);
525 let status_widget = Paragraph::new(text)
526 .style(Style::default())
527 .alignment(ratatui::layout::Alignment::Left);
528
529 f.render_widget(status_widget, area);
530 }
531
532 InputMode::SearchForward | InputMode::SearchBackward => {
533 let prefix = if matches!(app_state.input_mode, InputMode::SearchForward) {
535 "/"
536 } else {
537 "?"
538 };
539
540 let chunks = Layout::default()
542 .direction(Direction::Horizontal)
543 .constraints([
544 Constraint::Length(1), Constraint::Min(1), ])
547 .split(area);
548
549 let prefix_widget = Paragraph::new(prefix)
551 .style(Style::default())
552 .alignment(ratatui::layout::Alignment::Left);
553
554 f.render_widget(prefix_widget, chunks[0]);
555
556 let mut text_area = app_state.text_area.clone();
558 text_area.set_cursor_line_style(Style::default());
559 text_area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
560
561 f.render_widget(text_area.widget(), chunks[1]);
562 }
563
564 InputMode::Help => {
565 }
567
568 InputMode::LazyLoading => {
569 let status_widget = Paragraph::new(
571 "Sheet data not loaded... Press Enter to load, [ and ] to switch sheets, :addsheet <name> to add a sheet, :delsheet to delete current sheet, :q to quit, :q! to quit without saving",
572 )
573 .style(Style::default().fg(Color::LightYellow))
574 .alignment(ratatui::layout::Alignment::Left);
575
576 f.render_widget(status_widget, area);
577 }
578 }
579}
580
581fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) {
582 let overlay = Block::default()
584 .style(Style::default().bg(Color::Black).fg(Color::White))
585 .borders(Borders::ALL)
586 .border_style(Style::default().fg(Color::LightCyan));
587
588 f.render_widget(Clear, area);
589 f.render_widget(overlay, area);
590
591 let message = "Press Enter to load the sheet, [ and ] to switch sheets";
593 let width = message.len() as u16;
594 let x = area.x + (area.width.saturating_sub(width)) / 2;
595 let y = area.y + area.height / 2;
596
597 if x < area.width && y < area.height {
598 let message_area = Rect {
599 x,
600 y,
601 width: width.min(area.width),
602 height: 1,
603 };
604
605 let message_widget = Paragraph::new(message).style(
606 Style::default()
607 .fg(Color::LightYellow)
608 .add_modifier(Modifier::BOLD),
609 );
610
611 f.render_widget(message_widget, message_area);
612 }
613}
614
615fn draw_help_popup(f: &mut Frame, app_state: &mut AppState, area: Rect) {
616 f.render_widget(Clear, area);
618
619 let line_count = app_state.help_text.lines().count() as u16;
621 let content_height = line_count + 2; let max_line_width = app_state
624 .help_text
625 .lines()
626 .map(|line| line.len() as u16)
627 .max()
628 .unwrap_or(40);
629
630 let content_width = max_line_width + 4; let popup_width = content_width.min(area.width.saturating_sub(4));
634 let popup_height = content_height.min(area.height.saturating_sub(4));
635
636 let popup_x = (area.width.saturating_sub(popup_width)) / 2;
638 let popup_y = (area.height.saturating_sub(popup_height)) / 2;
639
640 let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
641
642 let visible_lines = popup_height.saturating_sub(2) as usize; app_state.help_visible_lines = visible_lines;
645
646 let line_count = app_state.help_text.lines().count();
647 let max_scroll = line_count.saturating_sub(visible_lines).max(0);
648
649 app_state.help_scroll = app_state.help_scroll.min(max_scroll);
650
651 let mut title = " [ESC/Enter to close] ".to_string();
652
653 if max_scroll > 0 {
654 let scroll_indicator = if app_state.help_scroll == 0 {
655 " [↓ or j to scroll] "
656 } else if app_state.help_scroll >= max_scroll {
657 " [↑ or k to scroll] "
658 } else {
659 " [↑↓ or j/k to scroll] "
660 };
661 title.push_str(scroll_indicator);
662 }
663
664 let help_block = Block::default()
665 .title(title)
666 .title_style(
667 Style::default()
668 .fg(Color::Yellow)
669 .add_modifier(Modifier::BOLD),
670 )
671 .borders(Borders::ALL)
672 .border_style(Style::default().fg(Color::LightCyan))
673 .style(Style::default().bg(Color::Blue).fg(Color::White));
674
675 let help_paragraph = Paragraph::new(app_state.help_text.clone())
677 .block(help_block)
678 .wrap(ratatui::widgets::Wrap { trim: false })
679 .scroll((app_state.help_scroll as u16, 0));
680
681 f.render_widget(help_paragraph, popup_area);
682}
683
684fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) {
685 let is_editing = matches!(app_state.input_mode, InputMode::Editing);
686 let sheet_names = app_state.workbook.get_sheet_names();
687 let current_index = app_state.workbook.get_current_sheet_index();
688
689 let file_name = app_state
690 .file_path
691 .file_name()
692 .and_then(|n| n.to_str())
693 .unwrap_or("Untitled");
694
695 let title_content = format!(" {file_name} ");
696
697 let title_width = title_content
698 .chars()
699 .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }) as u16;
700
701 let available_width = area.width.saturating_sub(title_width) as usize;
702
703 let mut tab_widths = Vec::new();
704 let mut total_width = 0;
705 let mut visible_tabs = Vec::new();
706
707 for (i, name) in sheet_names.iter().enumerate() {
708 let tab_width = name.len();
709
710 if total_width + tab_width <= available_width {
711 tab_widths.push(tab_width as u16);
712 total_width += tab_width;
713 visible_tabs.push(i);
714 } else {
715 if !visible_tabs.contains(¤t_index) {
717 while !visible_tabs.is_empty() && total_width + tab_width > available_width {
719 let removed_width = tab_widths.remove(0) as usize;
720 visible_tabs.remove(0);
721 total_width -= removed_width;
722 }
723
724 if total_width + tab_width <= available_width {
726 tab_widths.push(tab_width as u16);
727 visible_tabs.push(current_index);
728 }
729 }
730 break;
731 }
732 }
733
734 let max_title_width = (area.width * 2 / 3).min(title_width);
736
737 let horizontal_layout = Layout::default()
739 .direction(Direction::Horizontal)
740 .constraints([Constraint::Length(max_title_width), Constraint::Min(0)])
741 .split(area);
742
743 let title_style = if is_editing {
744 Style::default().bg(Color::DarkGray).fg(Color::Gray)
745 } else {
746 Style::default().bg(Color::DarkGray).fg(Color::White)
747 };
748
749 let title_widget = Paragraph::new(title_content).style(title_style);
750
751 f.render_widget(title_widget, horizontal_layout[0]);
752
753 let mut tab_constraints = Vec::new();
755 for &width in &tab_widths {
756 tab_constraints.push(Constraint::Length(width));
757 }
758 tab_constraints.push(Constraint::Min(0)); let tab_layout = Layout::default()
761 .direction(Direction::Horizontal)
762 .constraints(tab_constraints)
763 .split(horizontal_layout[1]);
764
765 for (layout_idx, &sheet_idx) in visible_tabs.iter().enumerate() {
767 if layout_idx >= tab_layout.len() - 1 {
768 break;
769 }
770
771 let name = &sheet_names[sheet_idx];
772 let is_current = sheet_idx == current_index;
773
774 let style = if is_editing {
775 if is_current {
776 Style::default().bg(Color::DarkGray).fg(Color::Gray)
777 } else {
778 Style::default().fg(Color::DarkGray)
779 }
780 } else if is_current {
781 Style::default().bg(Color::DarkGray).fg(Color::White)
782 } else {
783 Style::default()
784 };
785
786 let tab_widget = Paragraph::new(name.to_string())
787 .style(style)
788 .alignment(ratatui::layout::Alignment::Center);
789
790 f.render_widget(tab_widget, tab_layout[layout_idx]);
791 }
792
793 if visible_tabs.len() < sheet_names.len() {
795 let more_indicator = "...";
796 let indicator_style = Style::default().bg(Color::DarkGray).fg(Color::White);
797 let indicator_width = more_indicator.len() as u16;
798
799 let indicator_rect = Rect {
801 x: area.x + area.width - indicator_width,
802 y: area.y,
803 width: indicator_width,
804 height: 1,
805 };
806
807 let indicator_widget = Paragraph::new(more_indicator).style(indicator_style);
808 f.render_widget(indicator_widget, indicator_rect);
809 }
810}