1use anyhow::Result;
2use crossterm::{
3 ExecutableCommand,
4 event::{self, Event, KeyEventKind},
5 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
6};
7use ratatui::{
8 Frame, Terminal,
9 backend::CrosstermBackend,
10 layout::{Constraint, Direction, Layout, Rect},
11 style::{Color, Modifier, Style},
12 text::{Line, Span},
13 widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table},
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 enable_raw_mode()?;
26 let mut stdout = io::stdout();
27 stdout.execute(EnterAlternateScreen)?;
28 let backend = CrosstermBackend::new(stdout);
29 let mut terminal = Terminal::new(backend)?;
30
31 while !app_state.should_quit {
33 terminal.draw(|f| ui(f, &mut app_state))?;
34
35 if event::poll(Duration::from_millis(50))? {
36 if let Event::Key(key) = event::read()? {
37 if key.kind == KeyEventKind::Press {
38 handle_key_event(&mut app_state, key);
39 }
40 }
41 }
42 }
43
44 disable_raw_mode()?;
46 terminal.backend_mut().execute(LeaveAlternateScreen)?;
47 terminal.show_cursor()?;
48
49 Ok(())
50}
51
52fn update_visible_area(app_state: &mut AppState, area: Rect) {
53 app_state.visible_rows = (area.height as usize).saturating_sub(3);
55
56 app_state.ensure_column_visible(app_state.selected_cell.1);
58
59 let available_width = (area.width as usize).saturating_sub(7); let mut visible_cols = 0;
64 let mut width_used = 0;
65
66 for col_idx in app_state.start_col.. {
68 let col_width = app_state.get_column_width(col_idx);
69
70 if col_idx == app_state.start_col {
72 width_used += col_width;
73 visible_cols += 1;
74
75 if width_used >= available_width {
76 break;
77 }
78 }
79 else if width_used + col_width <= available_width {
81 width_used += col_width;
82 visible_cols += 1;
83 }
84 else if width_used < available_width {
86 visible_cols += 1;
87 break;
88 }
89 else {
91 break;
92 }
93 }
94
95 app_state.visible_cols = visible_cols.max(1);
97}
98
99fn ui(f: &mut Frame, app_state: &mut AppState) {
100 let chunks = Layout::default()
102 .direction(Direction::Vertical)
103 .constraints([
104 Constraint::Length(1), Constraint::Min(1), Constraint::Length(app_state.info_panel_height as u16), Constraint::Length(1), ])
109 .split(f.size());
110
111 draw_title_with_tabs(f, app_state, chunks[0]);
112
113 update_visible_area(app_state, chunks[1]);
114 draw_spreadsheet(f, app_state, chunks[1]);
115
116 draw_info_panel(f, app_state, chunks[2]);
117 draw_status_bar(f, app_state, chunks[3]);
118
119 if let InputMode::Help = app_state.input_mode {
121 draw_help_popup(f, app_state, f.size());
122 }
123}
124
125fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) {
126 let start_row = app_state.start_row;
128 let end_row = start_row + app_state.visible_rows - 1;
129 let start_col = app_state.start_col;
130 let end_col = start_col + app_state.visible_cols - 1;
131
132 let mut col_constraints = Vec::with_capacity(app_state.visible_cols + 1);
133 col_constraints.push(Constraint::Length(5)); for col in start_col..=end_col {
136 col_constraints.push(Constraint::Length(app_state.get_column_width(col) as u16));
137 }
138
139 let mut header_cells = Vec::with_capacity(app_state.visible_cols + 1);
140 header_cells.push(Cell::from(""));
141
142 for col in start_col..=end_col {
144 let col_name = index_to_col_name(col);
145 header_cells
146 .push(Cell::from(col_name).style(Style::default().bg(Color::Blue).fg(Color::White)));
147 }
148
149 let header_row = Row::new(header_cells).height(1);
150
151 let mut rows = Vec::with_capacity(app_state.visible_rows);
152
153 for row in start_row..=end_row {
155 let row_header =
156 Cell::from(row.to_string()).style(Style::default().bg(Color::Blue).fg(Color::White));
157
158 let mut cells = Vec::with_capacity(app_state.visible_cols + 1);
159 cells.push(row_header);
160
161 for col in start_col..=end_col {
163 let content = if app_state.selected_cell == (row, col)
164 && matches!(app_state.input_mode, InputMode::Editing)
165 {
166 let current_content = app_state.text_area.lines().join("\n");
168 let col_width = app_state.get_column_width(col);
169
170 let display_width = current_content
172 .chars()
173 .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 });
174
175 if display_width > col_width.saturating_sub(2) {
176 let mut result = String::with_capacity(col_width);
178 let mut cumulative_width = 0;
179
180 for c in current_content.chars().rev().take(col_width * 2) {
182 let char_width = if c.is_ascii() { 1 } else { 2 };
183 if cumulative_width + char_width <= col_width.saturating_sub(2) {
184 cumulative_width += char_width;
185 result.push(c);
186 } else {
187 break;
188 }
189 }
190
191 result.chars().rev().collect::<String>()
193 } else {
194 current_content
195 }
196 } else {
197 let content = app_state.get_cell_content(row, col);
199 let col_width = app_state.get_column_width(col);
200
201 let display_width = content
203 .chars()
204 .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 });
205
206 if display_width > col_width {
207 let mut result = String::with_capacity(col_width);
209 let mut current_width = 0;
210
211 for c in content.chars() {
212 let char_width = if c.is_ascii() { 1 } else { 2 };
213 if current_width + char_width < col_width {
214 result.push(c);
215 current_width += char_width;
216 } else {
217 break;
218 }
219 }
220
221 if !content.is_empty() && result.len() < content.len() {
222 result.push('…');
223 }
224
225 result
226 } else {
227 content
228 }
229 };
230
231 let style = if app_state.selected_cell == (row, col) {
233 Style::default().bg(Color::DarkGray).fg(Color::White)
234 } else if app_state.highlight_enabled && app_state.search_results.contains(&(row, col))
235 {
236 Style::default().bg(Color::Yellow).fg(Color::Black)
237 } else {
238 Style::default()
239 };
240
241 cells.push(Cell::from(content).style(style));
242 }
243
244 rows.push(Row::new(cells));
245 }
246
247 let table = Table::new(std::iter::once(header_row).chain(rows))
248 .block(Block::default().borders(Borders::ALL))
249 .highlight_style(Style::default().add_modifier(Modifier::BOLD))
250 .highlight_symbol(">> ")
251 .widths(&col_constraints);
252
253 f.render_widget(table, area);
254}
255
256fn parse_command(input: &str) -> Vec<Span> {
258 if input.is_empty() {
259 return vec![Span::raw("")];
260 }
261
262 let known_commands = [
263 "w",
264 "wq",
265 "q",
266 "q!",
267 "x",
268 "y",
269 "d",
270 "put",
271 "pu",
272 "nohlsearch",
273 "noh",
274 "help",
275 "delsheet",
276 ];
277
278 let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc"];
279
280 let special_keywords = ["fit", "min", "all", "h", "v", "horizontal", "vertical"];
281
282 if known_commands.contains(&input) {
284 return vec![Span::styled(input, Style::default().fg(Color::Yellow))];
285 }
286
287 let parts: Vec<&str> = input.split_whitespace().collect();
289 if parts.is_empty() {
290 return vec![Span::raw(input)];
291 }
292
293 let cmd = parts[0];
294
295 if commands_with_params.contains(&cmd) || (cmd.starts_with("ej") && cmd.len() <= 3) {
297 let mut spans = Vec::new();
298
299 spans.push(Span::styled(cmd, Style::default().fg(Color::Yellow)));
301
302 if parts.len() > 1 {
304 spans.push(Span::raw(" "));
305
306 for i in 1..parts.len() {
307 let style = if special_keywords.contains(&parts[i]) {
309 Style::default().fg(Color::Yellow) } else {
311 Style::default().fg(Color::LightCyan) };
313
314 spans.push(Span::styled(parts[i], style));
315
316 if i < parts.len() - 1 {
318 spans.push(Span::raw(" "));
319 }
320 }
321 }
322
323 return spans;
324 }
325
326 vec![Span::raw(input)]
328}
329
330fn draw_info_panel(f: &mut Frame, app_state: &AppState, area: Rect) {
331 let chunks = Layout::default()
332 .direction(Direction::Vertical)
333 .constraints([
334 Constraint::Percentage(50), Constraint::Percentage(50), ])
337 .split(area);
338
339 let (row, col) = app_state.selected_cell;
341 let cell_ref = cell_reference(app_state.selected_cell);
342
343 match app_state.input_mode {
345 InputMode::Editing => {
346 let title = format!(" Editing Cell {} ", cell_ref);
349 let edit_block = Block::default().borders(Borders::ALL).title(title);
350
351 f.render_widget(edit_block.clone(), chunks[0]);
352
353 let inner_area = edit_block.inner(chunks[0]);
355 let padded_area = Rect {
356 x: inner_area.x + 1, y: inner_area.y,
358 width: inner_area.width.saturating_sub(2), height: inner_area.height,
360 };
361
362 f.render_widget(app_state.text_area.widget(), padded_area);
363 }
364 _ => {
365 let content = app_state.get_cell_content(row, col);
367
368 let title = format!(" Cell {} Content ", cell_ref);
370 let cell_block = Block::default().borders(Borders::ALL).title(title);
371
372 f.render_widget(cell_block.clone(), chunks[0]);
373
374 let inner_area = cell_block.inner(chunks[0]);
376 let padded_area = Rect {
377 x: inner_area.x + 1, y: inner_area.y,
379 width: inner_area.width.saturating_sub(2), height: inner_area.height,
381 };
382
383 let cell_paragraph = Paragraph::new(content)
384 .wrap(ratatui::widgets::Wrap { trim: false })
385 .scroll((0, 0));
386
387 f.render_widget(cell_paragraph, padded_area);
388 }
389 }
390
391 let notification_block = Block::default()
393 .borders(Borders::ALL)
394 .title(" Notifications ");
395
396 f.render_widget(notification_block.clone(), chunks[1]);
397
398 let inner_area = notification_block.inner(chunks[1]);
400 let padded_area = Rect {
401 x: inner_area.x + 1, y: inner_area.y,
403 width: inner_area.width.saturating_sub(2), height: inner_area.height,
405 };
406
407 let notification_height = inner_area.height as usize;
409
410 let notifications_text = if app_state.notification_messages.is_empty() {
411 String::new()
412 } else if app_state.notification_messages.len() <= notification_height {
413 app_state.notification_messages.join("\n")
414 } else {
415 let start_idx = app_state.notification_messages.len() - notification_height;
416
417 let mut result = String::with_capacity(
418 app_state.notification_messages[start_idx..]
419 .iter()
420 .map(|s| s.len())
421 .sum::<usize>()
422 + notification_height, );
424
425 for (i, msg) in app_state.notification_messages[start_idx..]
426 .iter()
427 .enumerate()
428 {
429 if i > 0 {
430 result.push('\n');
431 }
432 result.push_str(msg);
433 }
434
435 result
436 };
437
438 let notification_paragraph = Paragraph::new(notifications_text)
439 .wrap(ratatui::widgets::Wrap { trim: false })
440 .scroll((0, 0));
441
442 f.render_widget(notification_paragraph, padded_area);
443}
444
445fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) {
446 match app_state.input_mode {
447 InputMode::Normal => {
448 let status =
449 "Input :help for operating instructions | hjkl=move [ ]=prev/next-sheet i=edit y=copy d=cut p=paste /=search N/n=prev/next-search-result :=command ".to_string();
450
451 let status_style = Style::default().bg(Color::Black).fg(Color::White);
452 let status_widget = Paragraph::new(status).style(status_style);
453 f.render_widget(status_widget, area);
454 }
455
456 InputMode::Editing => {
457 let status = format!(
458 "Editing cell {} (Enter=confirm, Esc=cancel)",
459 cell_reference(app_state.selected_cell)
460 );
461 let status_style = Style::default().bg(Color::Black).fg(Color::White);
462 let status_widget = Paragraph::new(status).style(status_style);
463 f.render_widget(status_widget, area);
464 }
465
466 InputMode::Command => {
467 let mut spans = vec![Span::styled(":", Style::default().fg(Color::White))];
469 let command_spans = parse_command(&app_state.input_buffer);
470 spans.extend(command_spans);
471
472 let text = Line::from(spans);
473 let status_style = Style::default().bg(Color::Black);
474 let status_widget = Paragraph::new(text).style(status_style);
475 f.render_widget(status_widget, area);
476 }
477
478 InputMode::SearchForward => {
479 let text_area = app_state.text_area.clone();
480
481 let chunks = Layout::default()
482 .direction(Direction::Horizontal)
483 .constraints([Constraint::Length(1), Constraint::Min(1)])
484 .split(area);
485
486 let prefix_widget =
487 Paragraph::new("/").style(Style::default().bg(Color::Black).fg(Color::White));
488 f.render_widget(prefix_widget, chunks[0]);
489
490 f.render_widget(text_area.widget(), chunks[1]);
491 }
492
493 InputMode::SearchBackward => {
494 let text_area = app_state.text_area.clone();
495
496 let chunks = Layout::default()
497 .direction(Direction::Horizontal)
498 .constraints([Constraint::Length(1), Constraint::Min(1)])
499 .split(area);
500
501 let prefix_widget =
502 Paragraph::new("?").style(Style::default().bg(Color::Black).fg(Color::White));
503 f.render_widget(prefix_widget, chunks[0]);
504
505 f.render_widget(text_area.widget(), chunks[1]);
506 }
507
508 InputMode::Help => {}
509 }
510}
511
512fn draw_help_popup(f: &mut Frame, app_state: &mut AppState, area: Rect) {
513 let overlay = Block::default()
514 .style(Style::default().bg(Color::Black))
515 .borders(Borders::NONE);
516 f.render_widget(Clear, area);
517 f.render_widget(overlay, area);
518
519 let line_count = app_state.help_text.lines().count() as u16;
520
521 let content_height = line_count + 2; let max_line_width = app_state
524 .help_text
525 .lines()
526 .map(|line| line.len() as u16)
527 .max()
528 .unwrap_or(40);
529
530 let content_width = max_line_width + 4; let popup_width = content_width.min(area.width.saturating_sub(4));
533 let popup_height = content_height.min(area.height.saturating_sub(4));
534
535 let popup_x = (area.width.saturating_sub(popup_width)) / 2;
537 let popup_y = (area.height.saturating_sub(popup_height)) / 2;
538
539 let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
540
541 let visible_lines = popup_height.saturating_sub(2) as usize; app_state.help_visible_lines = visible_lines;
543
544 let line_count = app_state.help_text.lines().count();
545 let max_scroll = line_count.saturating_sub(visible_lines).max(0);
546
547 app_state.help_scroll = app_state.help_scroll.min(max_scroll);
548
549 let mut title = " [ESC/Enter to close] ".to_string();
550
551 if max_scroll > 0 {
553 let scroll_indicator = if app_state.help_scroll == 0 {
554 " [↓ or j to scroll] "
555 } else if app_state.help_scroll >= max_scroll {
556 " [↑ or k to scroll] "
557 } else {
558 " [↑↓ or j/k to scroll] "
559 };
560 title.push_str(scroll_indicator);
561 }
562
563 let help_block = Block::default()
564 .title(title)
565 .title_style(
566 Style::default()
567 .fg(Color::Yellow)
568 .add_modifier(Modifier::BOLD),
569 )
570 .borders(Borders::ALL)
571 .border_style(Style::default().fg(Color::LightCyan))
572 .style(Style::default().bg(Color::Blue).fg(Color::White));
573
574 f.render_widget(help_block.clone(), popup_area);
575
576 let inner_area = help_block.inner(popup_area);
577 let padded_area = Rect {
578 x: inner_area.x + 1, y: inner_area.y,
580 width: inner_area.width.saturating_sub(2), height: inner_area.height,
582 };
583
584 let help_paragraph = Paragraph::new(app_state.help_text.clone())
585 .wrap(ratatui::widgets::Wrap { trim: false })
586 .scroll((app_state.help_scroll as u16, 0));
587
588 f.render_widget(help_paragraph, padded_area);
589}
590
591fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) {
592 let sheet_names = app_state.workbook.get_sheet_names();
593 let current_index = app_state.workbook.get_current_sheet_index();
594
595 let file_name = app_state
596 .file_path
597 .file_name()
598 .and_then(|n| n.to_str())
599 .unwrap_or("Untitled");
600
601 let title_content = format!(" {} ", file_name);
602
603 let title_width = title_content
604 .chars()
605 .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }) as u16;
606
607 let available_width = area.width.saturating_sub(title_width) as usize;
608
609 let mut tab_widths = Vec::new();
610 let mut total_width = 0;
611 let mut visible_tabs = Vec::new();
612 for (i, name) in sheet_names.iter().enumerate() {
613 let tab_width = name.len() + 2;
614
615 if total_width + tab_width <= available_width {
616 tab_widths.push(tab_width as u16);
617 total_width += tab_width;
618 visible_tabs.push(i);
619 } else {
620 if !visible_tabs.contains(¤t_index) {
621 while !visible_tabs.is_empty() && total_width + tab_width > available_width {
622 let removed_width = tab_widths.remove(0) as usize;
623 visible_tabs.remove(0);
624 total_width -= removed_width;
625 }
626
627 if total_width + tab_width <= available_width {
628 tab_widths.push(tab_width as u16);
629 visible_tabs.push(current_index);
630 }
631 }
632 break;
633 }
634 }
635
636 let max_title_width = (area.width * 2 / 3).min(title_width);
637
638 let horizontal_layout = Layout::default()
640 .direction(Direction::Horizontal)
641 .constraints([Constraint::Length(max_title_width), Constraint::Min(0)])
642 .split(area);
643
644 let title_widget = Paragraph::new(title_content.clone())
645 .style(Style::default().bg(Color::Blue).fg(Color::White));
646 f.render_widget(title_widget, horizontal_layout[0]);
647
648 let mut tab_constraints = Vec::new();
649 for &width in &tab_widths {
650 tab_constraints.push(Constraint::Length(width));
651 }
652
653 tab_constraints.push(Constraint::Min(0));
654
655 let tab_layout = Layout::default()
656 .direction(Direction::Horizontal)
657 .constraints(tab_constraints)
658 .split(horizontal_layout[1]);
659
660 for (layout_idx, &sheet_idx) in visible_tabs.iter().enumerate() {
661 if layout_idx >= tab_layout.len() - 1 {
662 break;
663 }
664
665 let name = &sheet_names[sheet_idx];
666 let is_current = sheet_idx == current_index;
667 let style = if is_current {
668 Style::default().bg(Color::LightBlue).fg(Color::White)
669 } else {
670 Style::default().fg(Color::White)
671 };
672
673 let tab_widget = Paragraph::new(name.to_string())
674 .style(style)
675 .alignment(ratatui::layout::Alignment::Center);
676 f.render_widget(tab_widget, tab_layout[layout_idx]);
677 }
678
679 if visible_tabs.len() < sheet_names.len() {
680 let more_indicator = "...";
681 let indicator_style = Style::default().bg(Color::DarkGray).fg(Color::White);
682 let indicator_width = more_indicator.len() as u16;
683
684 let indicator_rect = Rect {
685 x: area.x + area.width - indicator_width,
686 y: area.y,
687 width: indicator_width,
688 height: 1,
689 };
690
691 let indicator_widget = Paragraph::new(more_indicator).style(indicator_style);
692 f.render_widget(indicator_widget, indicator_rect);
693 }
694}