use anyhow::Result;
use crossterm::{
event::{self, Event, KeyEventKind},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table},
Frame, Terminal,
};
use std::{io, time::Duration};
use crate::app::AppState;
use crate::app::InputMode;
use crate::ui::handlers::handle_key_event;
use crate::utils::cell_reference;
use crate::utils::index_to_col_name;
pub fn run_app(mut app_state: AppState) -> Result<()> {
let mut terminal = setup_terminal()?;
while !app_state.should_quit {
terminal.draw(|f| ui(f, &mut app_state))?;
if event::poll(Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
handle_key_event(&mut app_state, key);
}
}
}
}
restore_terminal(&mut terminal)?;
Ok(())
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
stdout.execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
disable_raw_mode()?;
terminal.backend_mut().execute(LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn update_visible_area(app_state: &mut AppState, area: Rect) {
app_state.visible_rows = (area.height as usize).saturating_sub(3);
app_state.ensure_column_visible(app_state.selected_cell.1);
app_state.update_row_number_width();
let available_width = (area.width as usize).saturating_sub(app_state.row_number_width + 2);
let mut visible_cols = 0;
let mut width_used = 0;
for col_idx in app_state.start_col.. {
let col_width = app_state.get_column_width(col_idx);
if col_idx == app_state.start_col {
width_used += col_width;
visible_cols += 1;
if width_used >= available_width {
break;
}
} else if width_used + col_width <= available_width {
width_used += col_width;
visible_cols += 1;
} else if width_used < available_width {
visible_cols += 1;
break;
} else {
break;
}
}
app_state.visible_cols = visible_cols.max(1);
}
fn ui(f: &mut Frame, app_state: &mut AppState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(app_state.info_panel_height as u16), Constraint::Length(1), ])
.split(f.size());
draw_title_with_tabs(f, app_state, chunks[0]);
update_visible_area(app_state, chunks[1]);
draw_spreadsheet(f, app_state, chunks[1]);
draw_info_panel(f, app_state, chunks[2]);
draw_status_bar(f, app_state, chunks[3]);
if let InputMode::Help = app_state.input_mode {
draw_help_popup(f, app_state, f.size());
}
match app_state.input_mode {
InputMode::LazyLoading | InputMode::CommandInLazyLoading => {
let current_index = app_state.workbook.get_current_sheet_index();
if !app_state.workbook.is_sheet_loaded(current_index) {
draw_lazy_loading_overlay(f, app_state, chunks[1]);
} else if matches!(app_state.input_mode, InputMode::LazyLoading) {
app_state.input_mode = crate::app::InputMode::Normal;
}
}
_ => {}
}
}
fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) {
let start_row = app_state.start_row;
let end_row = start_row + app_state.visible_rows - 1;
let start_col = app_state.start_col;
let end_col = start_col + app_state.visible_cols - 1;
let mut constraints = Vec::with_capacity(app_state.visible_cols + 1);
constraints.push(Constraint::Length(app_state.row_number_width as u16));
for col in start_col..=end_col {
constraints.push(Constraint::Length(app_state.get_column_width(col) as u16));
}
let (table_block, header_style, cell_style) =
if matches!(app_state.input_mode, InputMode::Normal) {
(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightCyan)),
Style::default().bg(Color::DarkGray).fg(Color::Gray),
Style::default(),
)
} else {
(
Block::default().borders(Borders::ALL),
Style::default().fg(Color::DarkGray),
Style::default().fg(Color::DarkGray), )
};
let mut header_cells = Vec::with_capacity(app_state.visible_cols + 1);
header_cells.push(Cell::from("").style(header_style));
for col in start_col..=end_col {
let col_name = index_to_col_name(col);
header_cells.push(Cell::from(col_name).style(header_style));
}
let header = Row::new(header_cells).height(1);
let rows = (start_row..=end_row).map(|row| {
let mut cells = Vec::with_capacity(app_state.visible_cols + 1);
cells.push(Cell::from(row.to_string()).style(header_style));
for col in start_col..=end_col {
let content = if app_state.selected_cell == (row, col)
&& matches!(app_state.input_mode, InputMode::Editing)
{
let current_content = app_state.text_area.lines().join("\n");
let col_width = app_state.get_column_width(col);
let display_width = current_content
.chars()
.fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 });
if display_width > col_width.saturating_sub(2) {
let mut result = String::with_capacity(col_width);
let mut cumulative_width = 0;
for c in current_content.chars().rev().take(col_width * 2) {
let char_width = if c.is_ascii() { 1 } else { 2 };
if cumulative_width + char_width <= col_width.saturating_sub(2) {
cumulative_width += char_width;
result.push(c);
} else {
break;
}
}
result.chars().rev().collect::<String>()
} else {
current_content
}
} else {
let content = app_state.get_cell_content(row, col);
let col_width = app_state.get_column_width(col);
let display_width = content
.chars()
.fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 });
if display_width > col_width {
let mut result = String::with_capacity(col_width);
let mut current_width = 0;
for c in content.chars() {
let char_width = if c.is_ascii() { 1 } else { 2 };
if current_width + char_width < col_width {
result.push(c);
current_width += char_width;
} else {
break;
}
}
if !content.is_empty() && result.len() < content.len() {
result.push('…');
}
result
} else {
content
}
};
let style = if app_state.selected_cell == (row, col) {
Style::default().bg(Color::White).fg(Color::Black)
} else if app_state.highlight_enabled && app_state.search_results.contains(&(row, col))
{
Style::default().bg(Color::Yellow).fg(Color::Black)
} else {
Style::default()
};
cells.push(Cell::from(content).style(style));
}
Row::new(cells)
});
let table = Table::new(
std::iter::once(header).chain(rows),
)
.block(table_block)
.style(cell_style)
.widths(&constraints);
f.render_widget(table, area);
}
fn parse_command(input: &str) -> Vec<Span<'_>> {
if input.is_empty() {
return vec![Span::raw("")];
}
let known_commands = [
"w",
"wq",
"q",
"q!",
"x",
"y",
"d",
"put",
"pu",
"nohlsearch",
"noh",
"help",
"addsheet",
"delsheet",
];
let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc", "addsheet"];
let special_keywords = ["fit", "min", "all", "h", "v", "horizontal", "vertical"];
if known_commands.contains(&input) {
return vec![Span::styled(input, Style::default().fg(Color::Yellow))];
}
let parts: Vec<&str> = input.split_whitespace().collect();
if parts.is_empty() {
return vec![Span::raw(input)];
}
let cmd = parts[0];
if commands_with_params.contains(&cmd) || (cmd.starts_with("ej") && cmd.len() <= 3) {
let mut spans = Vec::new();
spans.push(Span::styled(cmd, Style::default().fg(Color::Yellow)));
if parts.len() > 1 {
spans.push(Span::raw(" "));
for i in 1..parts.len() {
let style = if special_keywords.contains(&parts[i]) {
Style::default().fg(Color::Yellow) } else {
Style::default().fg(Color::LightCyan) };
spans.push(Span::styled(parts[i], style));
if i < parts.len() - 1 {
spans.push(Span::raw(" "));
}
}
}
return spans;
}
vec![Span::raw(input)]
}
fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(50), Constraint::Percentage(50), ])
.split(area);
let (row, col) = app_state.selected_cell;
let cell_ref = cell_reference(app_state.selected_cell);
if let InputMode::Editing = app_state.input_mode {
let (vim_mode_str, mode_color) = if let Some(vim_state) = &app_state.vim_state {
match vim_state.mode {
crate::app::VimMode::Normal => ("NORMAL", Color::Green),
crate::app::VimMode::Insert => ("INSERT", Color::LightBlue),
crate::app::VimMode::Visual => ("VISUAL", Color::Yellow),
crate::app::VimMode::Operator(op) => {
let op_str = match op {
'y' => "YANK",
'd' => "DELETE",
'c' => "CHANGE",
_ => "OPERATOR",
};
(op_str, Color::LightRed)
}
}
} else {
("VIM", Color::White)
};
let title = Line::from(vec![
Span::raw(" Editing Cell "),
Span::raw(cell_ref.clone()),
Span::raw(" - "),
Span::styled(
vim_mode_str,
Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
let edit_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightCyan))
.title(title);
let inner_area = edit_block.inner(chunks[0]);
let padded_area = Rect {
x: inner_area.x + 1, y: inner_area.y,
width: inner_area.width.saturating_sub(2), height: inner_area.height,
};
f.render_widget(edit_block, chunks[0]);
f.render_widget(app_state.text_area.widget(), padded_area);
} else {
let content = app_state.get_cell_content(row, col);
let title = format!(" Cell {cell_ref} Content ");
let cell_block = Block::default().borders(Borders::ALL).title(title);
let cell_paragraph = Paragraph::new(content)
.block(cell_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(cell_paragraph, chunks[0]);
}
let notification_block = if matches!(app_state.input_mode, InputMode::Editing) {
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
" Notifications ",
Style::default().fg(Color::DarkGray),
))
} else {
Block::default()
.borders(Borders::ALL)
.title(" Notifications ")
};
let notification_height = notification_block.inner(chunks[1]).height as usize;
let notifications_text = if app_state.notification_messages.is_empty() {
String::new()
} else if app_state.notification_messages.len() <= notification_height {
app_state.notification_messages.join("\n")
} else {
let start_idx = app_state.notification_messages.len() - notification_height;
app_state.notification_messages[start_idx..].join("\n")
};
let notification_paragraph = Paragraph::new(notifications_text)
.block(notification_block)
.wrap(ratatui::widgets::Wrap { trim: false })
.style(if matches!(app_state.input_mode, InputMode::Editing) {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
});
f.render_widget(notification_paragraph, chunks[1]);
}
fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) {
match app_state.input_mode {
InputMode::Normal => {
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 ";
let status_widget = Paragraph::new(status)
.style(Style::default())
.alignment(ratatui::layout::Alignment::Left);
f.render_widget(status_widget, area);
}
InputMode::Editing => {
let status_widget = Paragraph::new("Press Esc to exit editing mode")
.style(Style::default().fg(Color::DarkGray))
.alignment(ratatui::layout::Alignment::Left);
f.render_widget(status_widget, area);
}
InputMode::Command | InputMode::CommandInLazyLoading => {
let mut spans = vec![Span::styled(":", Style::default())];
let command_spans = parse_command(&app_state.input_buffer);
spans.extend(command_spans);
let text = Line::from(spans);
let status_widget = Paragraph::new(text)
.style(Style::default())
.alignment(ratatui::layout::Alignment::Left);
f.render_widget(status_widget, area);
}
InputMode::SearchForward | InputMode::SearchBackward => {
let prefix = if matches!(app_state.input_mode, InputMode::SearchForward) {
"/"
} else {
"?"
};
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(1), Constraint::Min(1), ])
.split(area);
let prefix_widget = Paragraph::new(prefix)
.style(Style::default())
.alignment(ratatui::layout::Alignment::Left);
f.render_widget(prefix_widget, chunks[0]);
let mut text_area = app_state.text_area.clone();
text_area.set_cursor_line_style(Style::default());
text_area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
f.render_widget(text_area.widget(), chunks[1]);
}
InputMode::Help => {
}
InputMode::LazyLoading => {
let status_widget = Paragraph::new(
"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",
)
.style(Style::default().fg(Color::LightYellow))
.alignment(ratatui::layout::Alignment::Left);
f.render_widget(status_widget, area);
}
}
}
fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) {
let overlay = Block::default()
.style(Style::default().bg(Color::Black).fg(Color::White))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightCyan));
f.render_widget(Clear, area);
f.render_widget(overlay, area);
let message = "Press Enter to load the sheet, [ and ] to switch sheets";
let width = message.len() as u16;
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + area.height / 2;
if x < area.width && y < area.height {
let message_area = Rect {
x,
y,
width: width.min(area.width),
height: 1,
};
let message_widget = Paragraph::new(message).style(
Style::default()
.fg(Color::LightYellow)
.add_modifier(Modifier::BOLD),
);
f.render_widget(message_widget, message_area);
}
}
fn draw_help_popup(f: &mut Frame, app_state: &mut AppState, area: Rect) {
f.render_widget(Clear, area);
let line_count = app_state.help_text.lines().count() as u16;
let content_height = line_count + 2;
let max_line_width = app_state
.help_text
.lines()
.map(|line| line.len() as u16)
.max()
.unwrap_or(40);
let content_width = max_line_width + 4;
let popup_width = content_width.min(area.width.saturating_sub(4));
let popup_height = content_height.min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
let visible_lines = popup_height.saturating_sub(2) as usize; app_state.help_visible_lines = visible_lines;
let line_count = app_state.help_text.lines().count();
let max_scroll = line_count.saturating_sub(visible_lines).max(0);
app_state.help_scroll = app_state.help_scroll.min(max_scroll);
let mut title = " [ESC/Enter to close] ".to_string();
if max_scroll > 0 {
let scroll_indicator = if app_state.help_scroll == 0 {
" [↓ or j to scroll] "
} else if app_state.help_scroll >= max_scroll {
" [↑ or k to scroll] "
} else {
" [↑↓ or j/k to scroll] "
};
title.push_str(scroll_indicator);
}
let help_block = Block::default()
.title(title)
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightCyan))
.style(Style::default().bg(Color::Blue).fg(Color::White));
let help_paragraph = Paragraph::new(app_state.help_text.clone())
.block(help_block)
.wrap(ratatui::widgets::Wrap { trim: false })
.scroll((app_state.help_scroll as u16, 0));
f.render_widget(help_paragraph, popup_area);
}
fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) {
let is_editing = matches!(app_state.input_mode, InputMode::Editing);
let sheet_names = app_state.workbook.get_sheet_names();
let current_index = app_state.workbook.get_current_sheet_index();
let file_name = app_state
.file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Untitled");
let title_content = format!(" {file_name} ");
let title_width = title_content
.chars()
.fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }) as u16;
let available_width = area.width.saturating_sub(title_width) as usize;
let mut tab_widths = Vec::new();
let mut total_width = 0;
let mut visible_tabs = Vec::new();
for (i, name) in sheet_names.iter().enumerate() {
let tab_width = name.len();
if total_width + tab_width <= available_width {
tab_widths.push(tab_width as u16);
total_width += tab_width;
visible_tabs.push(i);
} else {
if !visible_tabs.contains(¤t_index) {
while !visible_tabs.is_empty() && total_width + tab_width > available_width {
let removed_width = tab_widths.remove(0) as usize;
visible_tabs.remove(0);
total_width -= removed_width;
}
if total_width + tab_width <= available_width {
tab_widths.push(tab_width as u16);
visible_tabs.push(current_index);
}
}
break;
}
}
let max_title_width = (area.width * 2 / 3).min(title_width);
let horizontal_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(max_title_width), Constraint::Min(0)])
.split(area);
let title_style = if is_editing {
Style::default().bg(Color::DarkGray).fg(Color::Gray)
} else {
Style::default().bg(Color::DarkGray).fg(Color::White)
};
let title_widget = Paragraph::new(title_content).style(title_style);
f.render_widget(title_widget, horizontal_layout[0]);
let mut tab_constraints = Vec::new();
for &width in &tab_widths {
tab_constraints.push(Constraint::Length(width));
}
tab_constraints.push(Constraint::Min(0));
let tab_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(tab_constraints)
.split(horizontal_layout[1]);
for (layout_idx, &sheet_idx) in visible_tabs.iter().enumerate() {
if layout_idx >= tab_layout.len() - 1 {
break;
}
let name = &sheet_names[sheet_idx];
let is_current = sheet_idx == current_index;
let style = if is_editing {
if is_current {
Style::default().bg(Color::DarkGray).fg(Color::Gray)
} else {
Style::default().fg(Color::DarkGray)
}
} else if is_current {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
};
let tab_widget = Paragraph::new(name.to_string())
.style(style)
.alignment(ratatui::layout::Alignment::Center);
f.render_widget(tab_widget, tab_layout[layout_idx]);
}
if visible_tabs.len() < sheet_names.len() {
let more_indicator = "...";
let indicator_style = Style::default().bg(Color::DarkGray).fg(Color::White);
let indicator_width = more_indicator.len() as u16;
let indicator_rect = Rect {
x: area.x + area.width - indicator_width,
y: area.y,
width: indicator_width,
height: 1,
};
let indicator_widget = Paragraph::new(more_indicator).style(indicator_style);
f.render_widget(indicator_widget, indicator_rect);
}
}