Skip to main content

excel_cli/ui/render/
mod.rs

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, Clear, Paragraph},
13    Frame, Terminal,
14};
15use std::{io, time::Duration};
16
17mod help_overlay;
18mod spreadsheet;
19mod status;
20
21use help_overlay::draw_help_popup;
22use spreadsheet::{draw_spreadsheet, draw_title_with_tabs, update_visible_area};
23use status::{draw_status_bar, status_bar_height};
24
25#[cfg(test)]
26use help_overlay::{help_entry_lines, help_overlay_lines};
27
28use crate::app::AppState;
29use crate::app::InputMode;
30use crate::app::VimMode;
31use crate::ui::handlers::handle_key_event;
32use crate::ui::theme;
33use crate::utils::cell_reference;
34
35pub fn run_app(mut app_state: AppState) -> Result<()> {
36    // Setup terminal
37    let mut terminal = setup_terminal()?;
38
39    // Main event loop
40    while !app_state.should_quit {
41        terminal.draw(|f| ui(f, &mut app_state))?;
42
43        if event::poll(Duration::from_millis(50))? {
44            if let Event::Key(key) = event::read()? {
45                if key.kind == KeyEventKind::Press {
46                    handle_key_event(&mut app_state, key);
47                }
48            }
49        }
50    }
51
52    // Restore terminal
53    restore_terminal(&mut terminal)?;
54
55    Ok(())
56}
57
58/// Setup the terminal for the application
59fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
60    enable_raw_mode()?;
61    let mut stdout = io::stdout();
62    stdout.execute(EnterAlternateScreen)?;
63
64    let backend = CrosstermBackend::new(stdout);
65    let terminal = Terminal::new(backend)?;
66
67    Ok(terminal)
68}
69
70/// Restore the terminal to its original state
71fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
72    disable_raw_mode()?;
73    terminal.backend_mut().execute(LeaveAlternateScreen)?;
74    terminal.show_cursor()?;
75
76    Ok(())
77}
78
79fn ui(f: &mut Frame, app_state: &mut AppState) {
80    let area = f.area();
81    f.render_widget(Clear, area);
82    let status_bar_height = status_bar_height(app_state, area.width);
83
84    let chunks = Layout::default()
85        .direction(Direction::Vertical)
86        .constraints([
87            Constraint::Length(1),
88            Constraint::Min(1),
89            Constraint::Length(app_state.info_panel_height as u16),
90            Constraint::Length(status_bar_height),
91        ])
92        .split(area);
93
94    draw_title_with_tabs(f, app_state, chunks[0]);
95
96    update_visible_area(app_state, chunks[1]);
97    draw_spreadsheet(f, app_state, chunks[1]);
98    draw_info_panel(f, app_state, chunks[2]);
99    if status_bar_height > 0 {
100        draw_status_bar(f, app_state, chunks[3]);
101    }
102
103    // If in help mode, draw the help popup over everything else
104    if let InputMode::Help = app_state.input_mode {
105        draw_help_popup(f, app_state, area);
106    }
107
108    // If in lazy loading mode or CommandInLazyLoading mode and the current sheet is not loaded, draw the lazy loading overlay
109    match app_state.input_mode {
110        InputMode::LazyLoading | InputMode::CommandInLazyLoading => {
111            let current_index = app_state.workbook.get_current_sheet_index();
112            if !app_state.workbook.is_sheet_loaded(current_index) {
113                draw_lazy_loading_overlay(f, app_state, chunks[1]);
114            } else if matches!(app_state.input_mode, InputMode::LazyLoading) {
115                // If the sheet is loaded, switch back to Normal mode
116                app_state.input_mode = crate::app::InputMode::Normal;
117            }
118        }
119        _ => {}
120    }
121}
122
123pub(super) fn display_width(text: &str) -> u16 {
124    text.chars()
125        .fold(0, |acc, ch| acc + if ch.is_ascii() { 1 } else { 2 })
126}
127
128pub(super) fn line_display_width(line: &Line<'_>) -> u16 {
129    line.spans
130        .iter()
131        .map(|span| display_width(&span.content))
132        .sum()
133}
134
135fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) {
136    if area.height < 4 {
137        if matches!(app_state.input_mode, InputMode::Editing) {
138            draw_editing_panel(f, app_state, area);
139        } else {
140            draw_cell_details(f, app_state, area);
141        }
142        return;
143    }
144
145    let chunks = Layout::default()
146        .direction(Direction::Vertical)
147        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
148        .split(area);
149
150    if matches!(app_state.input_mode, InputMode::Editing) {
151        draw_editing_panel(f, app_state, chunks[0]);
152    } else {
153        draw_cell_details(f, app_state, chunks[0]);
154    }
155    draw_notifications(f, app_state, chunks[1]);
156}
157
158fn draw_cell_details(f: &mut Frame, app_state: &AppState, area: Rect) {
159    let content = app_state.get_cell_content(app_state.selected_cell.0, app_state.selected_cell.1);
160    let cell_ref = cell_reference(app_state.selected_cell);
161    let value_type = cell_value_type(&content);
162    let length = content.chars().count();
163
164    let title = format!(" Cell {cell_ref}  {value_type}  Len {length} ");
165    let block = panel_block(title, theme::TEXT);
166    let paragraph = Paragraph::new(content)
167        .block(block)
168        .style(theme::surface())
169        .wrap(ratatui::widgets::Wrap { trim: false });
170    f.render_widget(paragraph, area);
171}
172
173fn draw_editing_panel(f: &mut Frame, app_state: &AppState, area: Rect) {
174    let cell_ref = cell_reference(app_state.selected_cell);
175    let mode = app_state.vim_state.as_ref().map(|state| state.mode);
176    let input_block = panel_block_line(editing_title_line(cell_ref, mode), theme::ACCENT);
177    let inner_area = input_block.inner(area);
178    let padded_area = Rect {
179        x: inner_area.x.saturating_add(1),
180        y: inner_area.y,
181        width: inner_area.width.saturating_sub(2),
182        height: inner_area.height,
183    };
184
185    f.render_widget(input_block, area);
186    f.render_widget(&app_state.text_area, padded_area);
187}
188
189fn draw_notifications(f: &mut Frame, app_state: &AppState, area: Rect) {
190    let lines = if app_state.notification_messages.is_empty() {
191        vec![Line::from(Span::styled(
192            "No notifications",
193            Style::default().fg(theme::TEXT_SECONDARY),
194        ))]
195    } else {
196        app_state
197            .notification_messages
198            .iter()
199            .rev()
200            .take(4)
201            .enumerate()
202            .map(|(index, message)| {
203                let color = if index == 0 {
204                    theme::TEXT
205                } else {
206                    theme::TEXT_SECONDARY
207                };
208                Line::from(Span::styled(message.clone(), Style::default().fg(color)))
209            })
210            .collect()
211    };
212
213    let paragraph = Paragraph::new(lines)
214        .block(panel_block(" NOTIFICATIONS ".to_string(), theme::TEXT))
215        .style(theme::surface())
216        .wrap(ratatui::widgets::Wrap { trim: false });
217    f.render_widget(paragraph, area);
218}
219
220fn panel_block(title: String, border_color: Color) -> Block<'static> {
221    panel_block_line(
222        Line::from(Span::styled(
223            title,
224            Style::default()
225                .fg(theme::TEXT)
226                .add_modifier(Modifier::BOLD),
227        )),
228        border_color,
229    )
230}
231
232fn panel_block_line(title: Line<'static>, border_color: Color) -> Block<'static> {
233    Block::default()
234        .borders(Borders::ALL)
235        .title(title)
236        .border_style(Style::default().fg(border_color))
237        .style(theme::surface())
238}
239
240fn editing_title_line(cell_ref: String, mode: Option<VimMode>) -> Line<'static> {
241    let mut spans = vec![
242        Span::styled(
243            " Editing Cell ",
244            Style::default()
245                .fg(theme::TEXT)
246                .add_modifier(Modifier::BOLD),
247        ),
248        Span::styled(
249            cell_ref,
250            Style::default()
251                .fg(theme::TEXT)
252                .add_modifier(Modifier::BOLD),
253        ),
254    ];
255
256    if let Some(mode) = mode {
257        spans.push(Span::styled(
258            " - ",
259            Style::default()
260                .fg(theme::TEXT)
261                .add_modifier(Modifier::BOLD),
262        ));
263        spans.push(Span::styled(
264            mode.to_string(),
265            Style::default()
266                .fg(vim_mode_color(mode))
267                .add_modifier(Modifier::BOLD),
268        ));
269    }
270
271    spans.push(Span::styled(
272        " ",
273        Style::default()
274            .fg(theme::TEXT)
275            .add_modifier(Modifier::BOLD),
276    ));
277
278    Line::from(spans)
279}
280
281fn vim_mode_color(mode: VimMode) -> Color {
282    match mode {
283        VimMode::Normal => theme::SUCCESS,
284        VimMode::Insert => theme::ACCENT,
285        VimMode::Visual => theme::SEARCH,
286        VimMode::Operator(_) => theme::WARNING,
287    }
288}
289
290fn cell_value_type(content: &str) -> &'static str {
291    if content.is_empty() {
292        "Blank"
293    } else if content.starts_with("Formula: ") {
294        "Formula"
295    } else if content.parse::<f64>().is_ok() {
296        "Number"
297    } else {
298        "String"
299    }
300}
301
302fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) {
303    // Create a semi-transparent overlay
304    let overlay = Block::default()
305        .style(theme::surface())
306        .borders(Borders::ALL)
307        .border_style(Style::default().fg(theme::ACCENT));
308
309    f.render_widget(Clear, area);
310    f.render_widget(overlay, area);
311
312    // Calculate center position for the message
313    let message = "Sheet not loaded   Enter load   [ ] switch sheet   : command";
314    let width = message.len() as u16;
315    let x = area.x + (area.width.saturating_sub(width)) / 2;
316    let y = area.y + area.height / 2;
317
318    if x < area.width && y < area.height {
319        let message_area = Rect {
320            x,
321            y,
322            width: width.min(area.width),
323            height: 1,
324        };
325
326        let message_widget = Paragraph::new(message).style(
327            Style::default()
328                .fg(theme::WARNING)
329                .add_modifier(Modifier::BOLD),
330        );
331
332        f.render_widget(message_widget, message_area);
333    }
334}
335
336#[cfg(test)]
337mod tests;