excel_cli/ui/
render.rs

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    // Setup terminal
25    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    // Main loop
32    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    // Restore terminal
45    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    // Calculate visible rows based on available height
54    app_state.visible_rows = (area.height as usize).saturating_sub(3);
55
56    // Ensure the selected column is visible
57    app_state.ensure_column_visible(app_state.selected_cell.1);
58
59    // Calculate available width for columns (subtract row numbers and borders)
60    let available_width = (area.width as usize).saturating_sub(7); // 5 for row numbers + 2 for borders
61
62    // Calculate how many columns can fit in the available width
63    let mut visible_cols = 0;
64    let mut width_used = 0;
65
66    // Start from the leftmost visible column and add columns until run out of space
67    for col_idx in app_state.start_col.. {
68        let col_width = app_state.get_column_width(col_idx);
69
70        // Always include the first column even if it's wider than available space
71        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        // For subsequent columns, add them if they fit completely
80        else if width_used + col_width <= available_width {
81            width_used += col_width;
82            visible_cols += 1;
83        }
84        // Excel-like behavior: include one partially visible column if there's any space left
85        else if width_used < available_width {
86            visible_cols += 1;
87            break;
88        }
89        // No more space available
90        else {
91            break;
92        }
93    }
94
95    // Ensure at least one column is visible
96    app_state.visible_cols = visible_cols.max(1);
97}
98
99fn ui(f: &mut Frame, app_state: &mut AppState) {
100    // Create the main layout
101    let chunks = Layout::default()
102        .direction(Direction::Vertical)
103        .constraints([
104            Constraint::Length(1), // Combined title bar and sheet tabs
105            Constraint::Min(1),    // Spreadsheet
106            Constraint::Length(app_state.info_panel_height as u16), // Info panel
107            Constraint::Length(1), // Status bar
108        ])
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 in help mode, draw the help popup over everything else
120    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    // Calculate visible row and column ranges
127    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)); // Row header width
134
135    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    // Add column headers
143    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    // Create data rows
154    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        // Add cells for this row
162        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                // Handle editing mode content
167                let current_content = app_state.text_area.lines().join("\n");
168                let col_width = app_state.get_column_width(col);
169
170                // Calculate display width
171                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                    // Truncate content if it's too wide
177                    let mut result = String::with_capacity(col_width);
178                    let mut cumulative_width = 0;
179
180                    // Process characters from the end to show the most recent input
181                    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                    // Reverse the characters to get the correct order
192                    result.chars().rev().collect::<String>()
193                } else {
194                    current_content
195                }
196            } else {
197                // Handle normal cell content
198                let content = app_state.get_cell_content(row, col);
199                let col_width = app_state.get_column_width(col);
200
201                // Calculate display width
202                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                    // Truncate content if it's too wide
208                    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            // Determine cell style
232            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
256// Parse command input and identify keywords and parameters for highlighting
257fn 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    // Check if input is a simple command without parameters
283    if known_commands.contains(&input) {
284        return vec![Span::styled(input, Style::default().fg(Color::Yellow))];
285    }
286
287    // Extract command and parameters
288    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    // Check if it's a known command with parameters
296    if commands_with_params.contains(&cmd) || (cmd.starts_with("ej") && cmd.len() <= 3) {
297        let mut spans = Vec::new();
298
299        // Add the command part with yellow color
300        spans.push(Span::styled(cmd, Style::default().fg(Color::Yellow)));
301
302        // Add parameters if they exist
303        if parts.len() > 1 {
304            spans.push(Span::raw(" "));
305
306            for i in 1..parts.len() {
307                // Determine style based on whether it's a special keyword
308                let style = if special_keywords.contains(&parts[i]) {
309                    Style::default().fg(Color::Yellow) // Keywords are yellow
310                } else {
311                    Style::default().fg(Color::LightCyan) // Parameters are cyan
312                };
313
314                spans.push(Span::styled(parts[i], style));
315
316                // Add space between parameters
317                if i < parts.len() - 1 {
318                    spans.push(Span::raw(" "));
319                }
320            }
321        }
322
323        return spans;
324    }
325
326    // For cell references or unknown commands, return as is
327    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), // Cell content/editing area
335            Constraint::Percentage(50), // Notifications
336        ])
337        .split(area);
338
339    // Get the cell reference
340    let (row, col) = app_state.selected_cell;
341    let cell_ref = cell_reference(app_state.selected_cell);
342
343    // Handle the top panel based on the input mode
344    match app_state.input_mode {
345        InputMode::Editing => {
346            // In editing mode, show the text area for editing
347            // Create a block for the editing area with title
348            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            // Calculate inner area with padding
354            let inner_area = edit_block.inner(chunks[0]);
355            let padded_area = Rect {
356                x: inner_area.x + 1, // Add 1 character padding on the left
357                y: inner_area.y,
358                width: inner_area.width.saturating_sub(2), // Subtract 2 for left and right padding
359                height: inner_area.height,
360            };
361
362            f.render_widget(app_state.text_area.widget(), padded_area);
363        }
364        _ => {
365            // Get cell content
366            let content = app_state.get_cell_content(row, col);
367
368            // Create block with title
369            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            // Calculate inner area with padding
375            let inner_area = cell_block.inner(chunks[0]);
376            let padded_area = Rect {
377                x: inner_area.x + 1, // Add 1 character padding on the left
378                y: inner_area.y,
379                width: inner_area.width.saturating_sub(2), // Subtract 2 for left and right padding
380                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    // Create notification block
392    let notification_block = Block::default()
393        .borders(Borders::ALL)
394        .title(" Notifications ");
395
396    f.render_widget(notification_block.clone(), chunks[1]);
397
398    // Calculate inner area with padding
399    let inner_area = notification_block.inner(chunks[1]);
400    let padded_area = Rect {
401        x: inner_area.x + 1, // Add 1 character padding on the left
402        y: inner_area.y,
403        width: inner_area.width.saturating_sub(2), // Subtract 2 for left and right padding
404        height: inner_area.height,
405    };
406
407    // Calculate how many notifications can be shown
408    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, // Account for newlines
423        );
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            // Create a styled text with different colors for command and parameters
468            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; // +2 for borders
522
523    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; // +4 for borders and padding
531
532    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    // Center the popup on screen
536    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; // Subtract 2 for top and bottom borders
542    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    // Add scroll indicators if content is scrollable
552    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, // Add 1 character padding on the left
579        y: inner_area.y,
580        width: inner_area.width.saturating_sub(2), // Subtract 2 for left and right padding
581        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(&current_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    // Create a two-column layout: title column and tab column
639    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}