excel_cli/ui/
render.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, Cell, Clear, Paragraph, Row, Table},
13    Frame, Terminal,
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    let mut terminal = setup_terminal()?;
26
27    // Main event loop
28    while !app_state.should_quit {
29        terminal.draw(|f| ui(f, &mut app_state))?;
30
31        if event::poll(Duration::from_millis(50))? {
32            if let Event::Key(key) = event::read()? {
33                if key.kind == KeyEventKind::Press {
34                    handle_key_event(&mut app_state, key);
35                }
36            }
37        }
38    }
39
40    // Restore terminal
41    restore_terminal(&mut terminal)?;
42
43    Ok(())
44}
45
46/// Setup the terminal for the application
47fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
48    enable_raw_mode()?;
49    let mut stdout = io::stdout();
50    stdout.execute(EnterAlternateScreen)?;
51
52    let backend = CrosstermBackend::new(stdout);
53    let terminal = Terminal::new(backend)?;
54
55    Ok(terminal)
56}
57
58/// Restore the terminal to its original state
59fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
60    disable_raw_mode()?;
61    terminal.backend_mut().execute(LeaveAlternateScreen)?;
62    terminal.show_cursor()?;
63
64    Ok(())
65}
66
67/// Update the visible area of the spreadsheet based on the available space
68fn update_visible_area(app_state: &mut AppState, area: Rect) {
69    // Calculate visible rows based on available height (subtract header and borders)
70    app_state.visible_rows = (area.height as usize).saturating_sub(3);
71
72    // Ensure the selected column is visible
73    app_state.ensure_column_visible(app_state.selected_cell.1);
74
75    // Update row number width based on the maximum row number
76    app_state.update_row_number_width();
77
78    // Calculate available width for columns (subtract row numbers and borders)
79    let available_width = (area.width as usize).saturating_sub(app_state.row_number_width + 2); // row_number_width + 2 for borders
80
81    // Calculate how many columns can fit in the available width
82    let mut visible_cols = 0;
83    let mut width_used = 0;
84
85    // Iterate through columns starting from the leftmost visible column
86    for col_idx in app_state.start_col.. {
87        let col_width = app_state.get_column_width(col_idx);
88
89        if col_idx == app_state.start_col {
90            // Always include the first column even if it's wider than available space
91            width_used += col_width;
92            visible_cols += 1;
93
94            if width_used >= available_width {
95                break;
96            }
97        } else if width_used + col_width <= available_width {
98            // Add columns that fit completely
99            width_used += col_width;
100            visible_cols += 1;
101        } else if width_used < available_width {
102            // Excel-like behavior: include one partially visible column
103            visible_cols += 1;
104            break;
105        } else {
106            // No more space available
107            break;
108        }
109    }
110
111    // Ensure at least one column is visible
112    app_state.visible_cols = visible_cols.max(1);
113}
114
115fn ui(f: &mut Frame, app_state: &mut AppState) {
116    // Create the main layout
117    let chunks = Layout::default()
118        .direction(Direction::Vertical)
119        .constraints([
120            Constraint::Length(1), // Combined title bar and sheet tabs
121            Constraint::Min(1),    // Spreadsheet
122            Constraint::Length(app_state.info_panel_height as u16), // Info panel
123            Constraint::Length(1), // Status bar
124        ])
125        .split(f.size());
126
127    draw_title_with_tabs(f, app_state, chunks[0]);
128
129    update_visible_area(app_state, chunks[1]);
130    draw_spreadsheet(f, app_state, chunks[1]);
131
132    draw_info_panel(f, app_state, chunks[2]);
133    draw_status_bar(f, app_state, chunks[3]);
134
135    // If in help mode, draw the help popup over everything else
136    if let InputMode::Help = app_state.input_mode {
137        draw_help_popup(f, app_state, f.size());
138    }
139
140    // If in lazy loading mode or CommandInLazyLoading mode and the current sheet is not loaded, draw the lazy loading overlay
141    match app_state.input_mode {
142        InputMode::LazyLoading | InputMode::CommandInLazyLoading => {
143            let current_index = app_state.workbook.get_current_sheet_index();
144            if !app_state.workbook.is_sheet_loaded(current_index) {
145                draw_lazy_loading_overlay(f, app_state, chunks[1]);
146            } else if matches!(app_state.input_mode, InputMode::LazyLoading) {
147                // If the sheet is loaded, switch back to Normal mode
148                app_state.input_mode = crate::app::InputMode::Normal;
149            }
150        }
151        _ => {}
152    }
153}
154
155fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) {
156    // Calculate visible row and column ranges
157    let start_row = app_state.start_row;
158    let end_row = start_row + app_state.visible_rows - 1;
159    let start_col = app_state.start_col;
160    let end_col = start_col + app_state.visible_cols - 1;
161
162    let mut constraints = Vec::with_capacity(app_state.visible_cols + 1);
163    constraints.push(Constraint::Length(app_state.row_number_width as u16)); // Dynamic row header width
164
165    for col in start_col..=end_col {
166        constraints.push(Constraint::Length(app_state.get_column_width(col) as u16));
167    }
168
169    // Set table style based on current mode
170    let (table_block, header_style, cell_style) =
171        if matches!(app_state.input_mode, InputMode::Normal) {
172            // In Normal mode, add color to the border of the data display area to indicate current focus
173            (
174                Block::default()
175                    .borders(Borders::ALL)
176                    .border_style(Style::default().fg(Color::LightCyan)),
177                Style::default().bg(Color::DarkGray).fg(Color::Gray),
178                Style::default(),
179            )
180        } else {
181            // In editing mode, dim the data display area
182            (
183                Block::default().borders(Borders::ALL),
184                Style::default().fg(Color::DarkGray),
185                Style::default().fg(Color::DarkGray), // Dimmed cell content
186            )
187        };
188
189    // Create header row
190    let mut header_cells = Vec::with_capacity(app_state.visible_cols + 1);
191    header_cells.push(Cell::from("").style(header_style));
192
193    // Add column headers
194    for col in start_col..=end_col {
195        let col_name = index_to_col_name(col);
196        header_cells.push(Cell::from(col_name).style(header_style));
197    }
198
199    let header = Row::new(header_cells).height(1);
200
201    // Create data rows
202    let rows = (start_row..=end_row).map(|row| {
203        let mut cells = Vec::with_capacity(app_state.visible_cols + 1);
204
205        // Add row header
206        cells.push(Cell::from(row.to_string()).style(header_style));
207
208        // Add cells for this row
209        for col in start_col..=end_col {
210            let content = if app_state.selected_cell == (row, col)
211                && matches!(app_state.input_mode, InputMode::Editing)
212            {
213                // Handle editing mode content
214                let current_content = app_state.text_area.lines().join("\n");
215                let col_width = app_state.get_column_width(col);
216
217                // Calculate display width
218                let display_width = current_content
219                    .chars()
220                    .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 });
221
222                if display_width > col_width.saturating_sub(2) {
223                    // Truncate content if it's too wide
224                    let mut result = String::with_capacity(col_width);
225                    let mut cumulative_width = 0;
226
227                    // Process characters from the end to show the most recent input
228                    for c in current_content.chars().rev().take(col_width * 2) {
229                        let char_width = if c.is_ascii() { 1 } else { 2 };
230                        if cumulative_width + char_width <= col_width.saturating_sub(2) {
231                            cumulative_width += char_width;
232                            result.push(c);
233                        } else {
234                            break;
235                        }
236                    }
237
238                    // Reverse the characters to get the correct order
239                    result.chars().rev().collect::<String>()
240                } else {
241                    current_content
242                }
243            } else {
244                // Handle normal cell content
245                let content = app_state.get_cell_content(row, col);
246                let col_width = app_state.get_column_width(col);
247
248                // Calculate display width
249                let display_width = content
250                    .chars()
251                    .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 });
252
253                if display_width > col_width {
254                    // Truncate content if it's too wide
255                    let mut result = String::with_capacity(col_width);
256                    let mut current_width = 0;
257
258                    for c in content.chars() {
259                        let char_width = if c.is_ascii() { 1 } else { 2 };
260                        if current_width + char_width < col_width {
261                            result.push(c);
262                            current_width += char_width;
263                        } else {
264                            break;
265                        }
266                    }
267
268                    if !content.is_empty() && result.len() < content.len() {
269                        result.push('…');
270                    }
271
272                    result
273                } else {
274                    content
275                }
276            };
277
278            // Determine cell style
279            let style = if app_state.selected_cell == (row, col) {
280                Style::default().bg(Color::White).fg(Color::Black)
281            } else if app_state.highlight_enabled && app_state.search_results.contains(&(row, col))
282            {
283                Style::default().bg(Color::Yellow).fg(Color::Black)
284            } else {
285                Style::default()
286            };
287
288            cells.push(Cell::from(content).style(style));
289        }
290
291        Row::new(cells)
292    });
293
294    // Create table with header and rows
295    let table = Table::new(
296        // Combine header and data rows
297        std::iter::once(header).chain(rows),
298    )
299    .block(table_block)
300    .style(cell_style)
301    .widths(&constraints);
302
303    f.render_widget(table, area);
304}
305
306// Parse command input and identify keywords and parameters for highlighting
307fn parse_command(input: &str) -> Vec<Span> {
308    if input.is_empty() {
309        return vec![Span::raw("")];
310    }
311
312    let known_commands = [
313        "w",
314        "wq",
315        "q",
316        "q!",
317        "x",
318        "y",
319        "d",
320        "put",
321        "pu",
322        "nohlsearch",
323        "noh",
324        "help",
325        "delsheet",
326    ];
327
328    let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc"];
329
330    let special_keywords = ["fit", "min", "all", "h", "v", "horizontal", "vertical"];
331
332    // Check if input is a simple command without parameters
333    if known_commands.contains(&input) {
334        return vec![Span::styled(input, Style::default().fg(Color::Yellow))];
335    }
336
337    // Extract command and parameters
338    let parts: Vec<&str> = input.split_whitespace().collect();
339    if parts.is_empty() {
340        return vec![Span::raw(input)];
341    }
342
343    let cmd = parts[0];
344
345    // Check if it's a known command with parameters
346    if commands_with_params.contains(&cmd) || (cmd.starts_with("ej") && cmd.len() <= 3) {
347        let mut spans = Vec::new();
348
349        // Add the command part with yellow color
350        spans.push(Span::styled(cmd, Style::default().fg(Color::Yellow)));
351
352        // Add parameters if they exist
353        if parts.len() > 1 {
354            spans.push(Span::raw(" "));
355
356            for i in 1..parts.len() {
357                // Determine style based on whether it's a special keyword
358                let style = if special_keywords.contains(&parts[i]) {
359                    Style::default().fg(Color::Yellow) // Keywords are yellow
360                } else {
361                    Style::default().fg(Color::LightCyan) // Parameters are cyan
362                };
363
364                spans.push(Span::styled(parts[i], style));
365
366                // Add space between parameters
367                if i < parts.len() - 1 {
368                    spans.push(Span::raw(" "));
369                }
370            }
371        }
372
373        return spans;
374    }
375
376    // For cell references or unknown commands, return as is
377    vec![Span::raw(input)]
378}
379
380fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) {
381    let chunks = Layout::default()
382        .direction(Direction::Vertical)
383        .constraints([
384            Constraint::Percentage(50), // Cell content/editing area
385            Constraint::Percentage(50), // Notifications
386        ])
387        .split(area);
388
389    // Get the cell reference
390    let (row, col) = app_state.selected_cell;
391    let cell_ref = cell_reference(app_state.selected_cell);
392
393    // Handle the top panel based on the input mode
394    if let InputMode::Editing = app_state.input_mode {
395        let (vim_mode_str, mode_color) = if let Some(vim_state) = &app_state.vim_state {
396            match vim_state.mode {
397                crate::app::VimMode::Normal => ("NORMAL", Color::Green),
398                crate::app::VimMode::Insert => ("INSERT", Color::LightBlue),
399                crate::app::VimMode::Visual => ("VISUAL", Color::Yellow),
400                crate::app::VimMode::Operator(op) => {
401                    let op_str = match op {
402                        'y' => "YANK",
403                        'd' => "DELETE",
404                        'c' => "CHANGE",
405                        _ => "OPERATOR",
406                    };
407                    (op_str, Color::LightRed)
408                }
409            }
410        } else {
411            ("VIM", Color::White)
412        };
413
414        let title = Line::from(vec![
415            Span::raw(" Editing Cell "),
416            Span::raw(cell_ref.clone()),
417            Span::raw(" - "),
418            Span::styled(
419                vim_mode_str,
420                Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
421            ),
422            Span::raw(" "),
423        ]);
424
425        let edit_block = Block::default()
426            .borders(Borders::ALL)
427            .border_style(Style::default().fg(Color::LightCyan))
428            .title(title);
429
430        // Calculate inner area with padding
431        let inner_area = edit_block.inner(chunks[0]);
432        let padded_area = Rect {
433            x: inner_area.x + 1, // Add 1 character padding on the left
434            y: inner_area.y,
435            width: inner_area.width.saturating_sub(2), // Subtract 2 for left and right padding
436            height: inner_area.height,
437        };
438
439        f.render_widget(edit_block, chunks[0]);
440        f.render_widget(app_state.text_area.widget(), padded_area);
441    } else {
442        // Get cell content
443        let content = app_state.get_cell_content(row, col);
444
445        let title = format!(" Cell {cell_ref} Content ");
446        let cell_block = Block::default().borders(Borders::ALL).title(title);
447
448        // Create paragraph with cell content
449        let cell_paragraph = Paragraph::new(content)
450            .block(cell_block)
451            .wrap(ratatui::widgets::Wrap { trim: false });
452
453        f.render_widget(cell_paragraph, chunks[0]);
454    }
455
456    // Create notification block
457    let notification_block = if matches!(app_state.input_mode, InputMode::Editing) {
458        Block::default()
459            .borders(Borders::ALL)
460            .border_style(Style::default().fg(Color::DarkGray))
461            .title(Span::styled(
462                " Notifications ",
463                Style::default().fg(Color::DarkGray),
464            ))
465    } else {
466        Block::default()
467            .borders(Borders::ALL)
468            .title(" Notifications ")
469    };
470
471    // Calculate how many notifications can be shown
472    let notification_height = notification_block.inner(chunks[1]).height as usize;
473
474    // Prepare notifications text
475    let notifications_text = if app_state.notification_messages.is_empty() {
476        String::new()
477    } else if app_state.notification_messages.len() <= notification_height {
478        app_state.notification_messages.join("\n")
479    } else {
480        // Show only the most recent notifications that fit
481        let start_idx = app_state.notification_messages.len() - notification_height;
482        app_state.notification_messages[start_idx..].join("\n")
483    };
484
485    let notification_paragraph = Paragraph::new(notifications_text)
486        .block(notification_block)
487        .wrap(ratatui::widgets::Wrap { trim: false })
488        .style(if matches!(app_state.input_mode, InputMode::Editing) {
489            Style::default().fg(Color::DarkGray)
490        } else {
491            Style::default()
492        });
493
494    f.render_widget(notification_paragraph, chunks[1]);
495}
496
497fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) {
498    match app_state.input_mode {
499        InputMode::Normal => {
500            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 ";
501
502            let status_widget = Paragraph::new(status)
503                .style(Style::default())
504                .alignment(ratatui::layout::Alignment::Left);
505
506            f.render_widget(status_widget, area);
507        }
508
509        InputMode::Editing => {
510            let status_widget = Paragraph::new("Press Esc to exit editing mode")
511                .style(Style::default().fg(Color::DarkGray))
512                .alignment(ratatui::layout::Alignment::Left);
513
514            f.render_widget(status_widget, area);
515        }
516
517        InputMode::Command | InputMode::CommandInLazyLoading => {
518            // Create a styled text with different colors for command and parameters
519            let mut spans = vec![Span::styled(":", Style::default())];
520            let command_spans = parse_command(&app_state.input_buffer);
521            spans.extend(command_spans);
522
523            let text = Line::from(spans);
524            let status_widget = Paragraph::new(text)
525                .style(Style::default())
526                .alignment(ratatui::layout::Alignment::Left);
527
528            f.render_widget(status_widget, area);
529        }
530
531        InputMode::SearchForward | InputMode::SearchBackward => {
532            // Get search prefix based on mode
533            let prefix = if matches!(app_state.input_mode, InputMode::SearchForward) {
534                "/"
535            } else {
536                "?"
537            };
538
539            // Split the area for search prefix and search input
540            let chunks = Layout::default()
541                .direction(Direction::Horizontal)
542                .constraints([
543                    Constraint::Length(1), // Search prefix
544                    Constraint::Min(1),    // Search input
545                ])
546                .split(area);
547
548            // Render search prefix
549            let prefix_widget = Paragraph::new(prefix)
550                .style(Style::default())
551                .alignment(ratatui::layout::Alignment::Left);
552
553            f.render_widget(prefix_widget, chunks[0]);
554
555            // Render search input with cursor visible
556            let mut text_area = app_state.text_area.clone();
557            text_area.set_cursor_line_style(Style::default());
558            text_area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
559
560            f.render_widget(text_area.widget(), chunks[1]);
561        }
562
563        InputMode::Help => {
564            // No status bar in help mode
565        }
566
567        InputMode::LazyLoading => {
568            // Show a status message for lazy loading mode
569            let status_widget = Paragraph::new(
570                "Sheet data not loaded... Press Enter to load, [ and ] to switch sheets, :delsheet to delete current sheet, :q to quit, :q! to quit without saving",
571            )
572            .style(Style::default().fg(Color::LightYellow))
573            .alignment(ratatui::layout::Alignment::Left);
574
575            f.render_widget(status_widget, area);
576        }
577    }
578}
579
580fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) {
581    // Create a semi-transparent overlay
582    let overlay = Block::default()
583        .style(Style::default().bg(Color::Black).fg(Color::White))
584        .borders(Borders::ALL)
585        .border_style(Style::default().fg(Color::LightCyan));
586
587    f.render_widget(Clear, area);
588    f.render_widget(overlay, area);
589
590    // Calculate center position for the message
591    let message = "Press Enter to load the sheet, [ and ] to switch sheets";
592    let width = message.len() as u16;
593    let x = area.x + (area.width.saturating_sub(width)) / 2;
594    let y = area.y + area.height / 2;
595
596    if x < area.width && y < area.height {
597        let message_area = Rect {
598            x,
599            y,
600            width: width.min(area.width),
601            height: 1,
602        };
603
604        let message_widget = Paragraph::new(message).style(
605            Style::default()
606                .fg(Color::LightYellow)
607                .add_modifier(Modifier::BOLD),
608        );
609
610        f.render_widget(message_widget, message_area);
611    }
612}
613
614fn draw_help_popup(f: &mut Frame, app_state: &mut AppState, area: Rect) {
615    // Clear the background
616    f.render_widget(Clear, area);
617
618    // Calculate popup dimensions
619    let line_count = app_state.help_text.lines().count() as u16;
620    let content_height = line_count + 2; // +2 for borders
621
622    let max_line_width = app_state
623        .help_text
624        .lines()
625        .map(|line| line.len() as u16)
626        .max()
627        .unwrap_or(40);
628
629    let content_width = max_line_width + 4; // +4 for borders and padding
630
631    // Ensure popup fits within screen
632    let popup_width = content_width.min(area.width.saturating_sub(4));
633    let popup_height = content_height.min(area.height.saturating_sub(4));
634
635    // Center the popup on screen
636    let popup_x = (area.width.saturating_sub(popup_width)) / 2;
637    let popup_y = (area.height.saturating_sub(popup_height)) / 2;
638
639    let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
640
641    // Calculate scrolling parameters
642    let visible_lines = popup_height.saturating_sub(2) as usize; // Subtract 2 for top and bottom borders
643    app_state.help_visible_lines = visible_lines;
644
645    let line_count = app_state.help_text.lines().count();
646    let max_scroll = line_count.saturating_sub(visible_lines).max(0);
647
648    app_state.help_scroll = app_state.help_scroll.min(max_scroll);
649
650    let mut title = " [ESC/Enter to close] ".to_string();
651
652    if max_scroll > 0 {
653        let scroll_indicator = if app_state.help_scroll == 0 {
654            " [↓ or j to scroll] "
655        } else if app_state.help_scroll >= max_scroll {
656            " [↑ or k to scroll] "
657        } else {
658            " [↑↓ or j/k to scroll] "
659        };
660        title.push_str(scroll_indicator);
661    }
662
663    let help_block = Block::default()
664        .title(title)
665        .title_style(
666            Style::default()
667                .fg(Color::Yellow)
668                .add_modifier(Modifier::BOLD),
669        )
670        .borders(Borders::ALL)
671        .border_style(Style::default().fg(Color::LightCyan))
672        .style(Style::default().bg(Color::Blue).fg(Color::White));
673
674    // Create paragraph with help text
675    let help_paragraph = Paragraph::new(app_state.help_text.clone())
676        .block(help_block)
677        .wrap(ratatui::widgets::Wrap { trim: false })
678        .scroll((app_state.help_scroll as u16, 0));
679
680    f.render_widget(help_paragraph, popup_area);
681}
682
683fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) {
684    let is_editing = matches!(app_state.input_mode, InputMode::Editing);
685    let sheet_names = app_state.workbook.get_sheet_names();
686    let current_index = app_state.workbook.get_current_sheet_index();
687
688    let file_name = app_state
689        .file_path
690        .file_name()
691        .and_then(|n| n.to_str())
692        .unwrap_or("Untitled");
693
694    let title_content = format!(" {file_name} ");
695
696    let title_width = title_content
697        .chars()
698        .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }) as u16;
699
700    let available_width = area.width.saturating_sub(title_width) as usize;
701
702    let mut tab_widths = Vec::new();
703    let mut total_width = 0;
704    let mut visible_tabs = Vec::new();
705
706    for (i, name) in sheet_names.iter().enumerate() {
707        let tab_width = name.len();
708
709        if total_width + tab_width <= available_width {
710            tab_widths.push(tab_width as u16);
711            total_width += tab_width;
712            visible_tabs.push(i);
713        } else {
714            // If current tab isn't visible, make room for it
715            if !visible_tabs.contains(&current_index) {
716                // Remove tabs from the beginning until there's enough space
717                while !visible_tabs.is_empty() && total_width + tab_width > available_width {
718                    let removed_width = tab_widths.remove(0) as usize;
719                    visible_tabs.remove(0);
720                    total_width -= removed_width;
721                }
722
723                // Add current tab if there's now enough space
724                if total_width + tab_width <= available_width {
725                    tab_widths.push(tab_width as u16);
726                    visible_tabs.push(current_index);
727                }
728            }
729            break;
730        }
731    }
732
733    // Limit title width to at most 2/3 of the area
734    let max_title_width = (area.width * 2 / 3).min(title_width);
735
736    // Create a two-column layout: title column and tab column
737    let horizontal_layout = Layout::default()
738        .direction(Direction::Horizontal)
739        .constraints([Constraint::Length(max_title_width), Constraint::Min(0)])
740        .split(area);
741
742    let title_style = if is_editing {
743        Style::default().bg(Color::DarkGray).fg(Color::Gray)
744    } else {
745        Style::default().bg(Color::DarkGray).fg(Color::White)
746    };
747
748    let title_widget = Paragraph::new(title_content).style(title_style);
749
750    f.render_widget(title_widget, horizontal_layout[0]);
751
752    // Create constraints for tab layout
753    let mut tab_constraints = Vec::new();
754    for &width in &tab_widths {
755        tab_constraints.push(Constraint::Length(width));
756    }
757    tab_constraints.push(Constraint::Min(0)); // Filler space
758
759    let tab_layout = Layout::default()
760        .direction(Direction::Horizontal)
761        .constraints(tab_constraints)
762        .split(horizontal_layout[1]);
763
764    // Render each visible tab
765    for (layout_idx, &sheet_idx) in visible_tabs.iter().enumerate() {
766        if layout_idx >= tab_layout.len() - 1 {
767            break;
768        }
769
770        let name = &sheet_names[sheet_idx];
771        let is_current = sheet_idx == current_index;
772
773        let style = if is_editing {
774            if is_current {
775                Style::default().bg(Color::DarkGray).fg(Color::Gray)
776            } else {
777                Style::default().fg(Color::DarkGray)
778            }
779        } else if is_current {
780            Style::default().bg(Color::DarkGray).fg(Color::White)
781        } else {
782            Style::default()
783        };
784
785        let tab_widget = Paragraph::new(name.to_string())
786            .style(style)
787            .alignment(ratatui::layout::Alignment::Center);
788
789        f.render_widget(tab_widget, tab_layout[layout_idx]);
790    }
791
792    // Show indicator if not all tabs are visible
793    if visible_tabs.len() < sheet_names.len() {
794        let more_indicator = "...";
795        let indicator_style = Style::default().bg(Color::DarkGray).fg(Color::White);
796        let indicator_width = more_indicator.len() as u16;
797
798        // Position indicator at the right edge
799        let indicator_rect = Rect {
800            x: area.x + area.width - indicator_width,
801            y: area.y,
802            width: indicator_width,
803            height: 1,
804        };
805
806        let indicator_widget = Paragraph::new(more_indicator).style(indicator_style);
807        f.render_widget(indicator_widget, indicator_rect);
808    }
809}