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 let mut terminal = setup_terminal()?;
38
39 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(&mut terminal)?;
54
55 Ok(())
56}
57
58fn 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
70fn 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 let InputMode::Help = app_state.input_mode {
105 draw_help_popup(f, app_state, area);
106 }
107
108 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 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 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 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;