Skip to main content

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        "addsheet",
326        "delsheet",
327    ];
328
329    let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc", "addsheet"];
330
331    let special_keywords = ["fit", "min", "all", "h", "v", "horizontal", "vertical"];
332
333    // Check if input is a simple command without parameters
334    if known_commands.contains(&input) {
335        return vec![Span::styled(input, Style::default().fg(Color::Yellow))];
336    }
337
338    // Extract command and parameters
339    let parts: Vec<&str> = input.split_whitespace().collect();
340    if parts.is_empty() {
341        return vec![Span::raw(input)];
342    }
343
344    let cmd = parts[0];
345
346    // Check if it's a known command with parameters
347    if commands_with_params.contains(&cmd) || (cmd.starts_with("ej") && cmd.len() <= 3) {
348        let mut spans = Vec::new();
349
350        // Add the command part with yellow color
351        spans.push(Span::styled(cmd, Style::default().fg(Color::Yellow)));
352
353        // Add parameters if they exist
354        if parts.len() > 1 {
355            spans.push(Span::raw(" "));
356
357            for i in 1..parts.len() {
358                // Determine style based on whether it's a special keyword
359                let style = if special_keywords.contains(&parts[i]) {
360                    Style::default().fg(Color::Yellow) // Keywords are yellow
361                } else {
362                    Style::default().fg(Color::LightCyan) // Parameters are cyan
363                };
364
365                spans.push(Span::styled(parts[i], style));
366
367                // Add space between parameters
368                if i < parts.len() - 1 {
369                    spans.push(Span::raw(" "));
370                }
371            }
372        }
373
374        return spans;
375    }
376
377    // For cell references or unknown commands, return as is
378    vec![Span::raw(input)]
379}
380
381fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) {
382    let chunks = Layout::default()
383        .direction(Direction::Vertical)
384        .constraints([
385            Constraint::Percentage(50), // Cell content/editing area
386            Constraint::Percentage(50), // Notifications
387        ])
388        .split(area);
389
390    // Get the cell reference
391    let (row, col) = app_state.selected_cell;
392    let cell_ref = cell_reference(app_state.selected_cell);
393
394    // Handle the top panel based on the input mode
395    if let InputMode::Editing = app_state.input_mode {
396        let (vim_mode_str, mode_color) = if let Some(vim_state) = &app_state.vim_state {
397            match vim_state.mode {
398                crate::app::VimMode::Normal => ("NORMAL", Color::Green),
399                crate::app::VimMode::Insert => ("INSERT", Color::LightBlue),
400                crate::app::VimMode::Visual => ("VISUAL", Color::Yellow),
401                crate::app::VimMode::Operator(op) => {
402                    let op_str = match op {
403                        'y' => "YANK",
404                        'd' => "DELETE",
405                        'c' => "CHANGE",
406                        _ => "OPERATOR",
407                    };
408                    (op_str, Color::LightRed)
409                }
410            }
411        } else {
412            ("VIM", Color::White)
413        };
414
415        let title = Line::from(vec![
416            Span::raw(" Editing Cell "),
417            Span::raw(cell_ref.clone()),
418            Span::raw(" - "),
419            Span::styled(
420                vim_mode_str,
421                Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
422            ),
423            Span::raw(" "),
424        ]);
425
426        let edit_block = Block::default()
427            .borders(Borders::ALL)
428            .border_style(Style::default().fg(Color::LightCyan))
429            .title(title);
430
431        // Calculate inner area with padding
432        let inner_area = edit_block.inner(chunks[0]);
433        let padded_area = Rect {
434            x: inner_area.x + 1, // Add 1 character padding on the left
435            y: inner_area.y,
436            width: inner_area.width.saturating_sub(2), // Subtract 2 for left and right padding
437            height: inner_area.height,
438        };
439
440        f.render_widget(edit_block, chunks[0]);
441        f.render_widget(app_state.text_area.widget(), padded_area);
442    } else {
443        // Get cell content
444        let content = app_state.get_cell_content(row, col);
445
446        let title = format!(" Cell {cell_ref} Content ");
447        let cell_block = Block::default().borders(Borders::ALL).title(title);
448
449        // Create paragraph with cell content
450        let cell_paragraph = Paragraph::new(content)
451            .block(cell_block)
452            .wrap(ratatui::widgets::Wrap { trim: false });
453
454        f.render_widget(cell_paragraph, chunks[0]);
455    }
456
457    // Create notification block
458    let notification_block = if matches!(app_state.input_mode, InputMode::Editing) {
459        Block::default()
460            .borders(Borders::ALL)
461            .border_style(Style::default().fg(Color::DarkGray))
462            .title(Span::styled(
463                " Notifications ",
464                Style::default().fg(Color::DarkGray),
465            ))
466    } else {
467        Block::default()
468            .borders(Borders::ALL)
469            .title(" Notifications ")
470    };
471
472    // Calculate how many notifications can be shown
473    let notification_height = notification_block.inner(chunks[1]).height as usize;
474
475    // Prepare notifications text
476    let notifications_text = if app_state.notification_messages.is_empty() {
477        String::new()
478    } else if app_state.notification_messages.len() <= notification_height {
479        app_state.notification_messages.join("\n")
480    } else {
481        // Show only the most recent notifications that fit
482        let start_idx = app_state.notification_messages.len() - notification_height;
483        app_state.notification_messages[start_idx..].join("\n")
484    };
485
486    let notification_paragraph = Paragraph::new(notifications_text)
487        .block(notification_block)
488        .wrap(ratatui::widgets::Wrap { trim: false })
489        .style(if matches!(app_state.input_mode, InputMode::Editing) {
490            Style::default().fg(Color::DarkGray)
491        } else {
492            Style::default()
493        });
494
495    f.render_widget(notification_paragraph, chunks[1]);
496}
497
498fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) {
499    match app_state.input_mode {
500        InputMode::Normal => {
501            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 ";
502
503            let status_widget = Paragraph::new(status)
504                .style(Style::default())
505                .alignment(ratatui::layout::Alignment::Left);
506
507            f.render_widget(status_widget, area);
508        }
509
510        InputMode::Editing => {
511            let status_widget = Paragraph::new("Press Esc to exit editing mode")
512                .style(Style::default().fg(Color::DarkGray))
513                .alignment(ratatui::layout::Alignment::Left);
514
515            f.render_widget(status_widget, area);
516        }
517
518        InputMode::Command | InputMode::CommandInLazyLoading => {
519            // Create a styled text with different colors for command and parameters
520            let mut spans = vec![Span::styled(":", Style::default())];
521            let command_spans = parse_command(&app_state.input_buffer);
522            spans.extend(command_spans);
523
524            let text = Line::from(spans);
525            let status_widget = Paragraph::new(text)
526                .style(Style::default())
527                .alignment(ratatui::layout::Alignment::Left);
528
529            f.render_widget(status_widget, area);
530        }
531
532        InputMode::SearchForward | InputMode::SearchBackward => {
533            // Get search prefix based on mode
534            let prefix = if matches!(app_state.input_mode, InputMode::SearchForward) {
535                "/"
536            } else {
537                "?"
538            };
539
540            // Split the area for search prefix and search input
541            let chunks = Layout::default()
542                .direction(Direction::Horizontal)
543                .constraints([
544                    Constraint::Length(1), // Search prefix
545                    Constraint::Min(1),    // Search input
546                ])
547                .split(area);
548
549            // Render search prefix
550            let prefix_widget = Paragraph::new(prefix)
551                .style(Style::default())
552                .alignment(ratatui::layout::Alignment::Left);
553
554            f.render_widget(prefix_widget, chunks[0]);
555
556            // Render search input with cursor visible
557            let mut text_area = app_state.text_area.clone();
558            text_area.set_cursor_line_style(Style::default());
559            text_area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
560
561            f.render_widget(text_area.widget(), chunks[1]);
562        }
563
564        InputMode::Help => {
565            // No status bar in help mode
566        }
567
568        InputMode::LazyLoading => {
569            // Show a status message for lazy loading mode
570            let status_widget = Paragraph::new(
571                "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",
572            )
573            .style(Style::default().fg(Color::LightYellow))
574            .alignment(ratatui::layout::Alignment::Left);
575
576            f.render_widget(status_widget, area);
577        }
578    }
579}
580
581fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) {
582    // Create a semi-transparent overlay
583    let overlay = Block::default()
584        .style(Style::default().bg(Color::Black).fg(Color::White))
585        .borders(Borders::ALL)
586        .border_style(Style::default().fg(Color::LightCyan));
587
588    f.render_widget(Clear, area);
589    f.render_widget(overlay, area);
590
591    // Calculate center position for the message
592    let message = "Press Enter to load the sheet, [ and ] to switch sheets";
593    let width = message.len() as u16;
594    let x = area.x + (area.width.saturating_sub(width)) / 2;
595    let y = area.y + area.height / 2;
596
597    if x < area.width && y < area.height {
598        let message_area = Rect {
599            x,
600            y,
601            width: width.min(area.width),
602            height: 1,
603        };
604
605        let message_widget = Paragraph::new(message).style(
606            Style::default()
607                .fg(Color::LightYellow)
608                .add_modifier(Modifier::BOLD),
609        );
610
611        f.render_widget(message_widget, message_area);
612    }
613}
614
615fn draw_help_popup(f: &mut Frame, app_state: &mut AppState, area: Rect) {
616    // Clear the background
617    f.render_widget(Clear, area);
618
619    // Calculate popup dimensions
620    let line_count = app_state.help_text.lines().count() as u16;
621    let content_height = line_count + 2; // +2 for borders
622
623    let max_line_width = app_state
624        .help_text
625        .lines()
626        .map(|line| line.len() as u16)
627        .max()
628        .unwrap_or(40);
629
630    let content_width = max_line_width + 4; // +4 for borders and padding
631
632    // Ensure popup fits within screen
633    let popup_width = content_width.min(area.width.saturating_sub(4));
634    let popup_height = content_height.min(area.height.saturating_sub(4));
635
636    // Center the popup on screen
637    let popup_x = (area.width.saturating_sub(popup_width)) / 2;
638    let popup_y = (area.height.saturating_sub(popup_height)) / 2;
639
640    let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
641
642    // Calculate scrolling parameters
643    let visible_lines = popup_height.saturating_sub(2) as usize; // Subtract 2 for top and bottom borders
644    app_state.help_visible_lines = visible_lines;
645
646    let line_count = app_state.help_text.lines().count();
647    let max_scroll = line_count.saturating_sub(visible_lines).max(0);
648
649    app_state.help_scroll = app_state.help_scroll.min(max_scroll);
650
651    let mut title = " [ESC/Enter to close] ".to_string();
652
653    if max_scroll > 0 {
654        let scroll_indicator = if app_state.help_scroll == 0 {
655            " [↓ or j to scroll] "
656        } else if app_state.help_scroll >= max_scroll {
657            " [↑ or k to scroll] "
658        } else {
659            " [↑↓ or j/k to scroll] "
660        };
661        title.push_str(scroll_indicator);
662    }
663
664    let help_block = Block::default()
665        .title(title)
666        .title_style(
667            Style::default()
668                .fg(Color::Yellow)
669                .add_modifier(Modifier::BOLD),
670        )
671        .borders(Borders::ALL)
672        .border_style(Style::default().fg(Color::LightCyan))
673        .style(Style::default().bg(Color::Blue).fg(Color::White));
674
675    // Create paragraph with help text
676    let help_paragraph = Paragraph::new(app_state.help_text.clone())
677        .block(help_block)
678        .wrap(ratatui::widgets::Wrap { trim: false })
679        .scroll((app_state.help_scroll as u16, 0));
680
681    f.render_widget(help_paragraph, popup_area);
682}
683
684fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) {
685    let is_editing = matches!(app_state.input_mode, InputMode::Editing);
686    let sheet_names = app_state.workbook.get_sheet_names();
687    let current_index = app_state.workbook.get_current_sheet_index();
688
689    let file_name = app_state
690        .file_path
691        .file_name()
692        .and_then(|n| n.to_str())
693        .unwrap_or("Untitled");
694
695    let title_content = format!(" {file_name} ");
696
697    let title_width = title_content
698        .chars()
699        .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }) as u16;
700
701    let available_width = area.width.saturating_sub(title_width) as usize;
702
703    let mut tab_widths = Vec::new();
704    let mut total_width = 0;
705    let mut visible_tabs = Vec::new();
706
707    for (i, name) in sheet_names.iter().enumerate() {
708        let tab_width = name.len();
709
710        if total_width + tab_width <= available_width {
711            tab_widths.push(tab_width as u16);
712            total_width += tab_width;
713            visible_tabs.push(i);
714        } else {
715            // If current tab isn't visible, make room for it
716            if !visible_tabs.contains(&current_index) {
717                // Remove tabs from the beginning until there's enough space
718                while !visible_tabs.is_empty() && total_width + tab_width > available_width {
719                    let removed_width = tab_widths.remove(0) as usize;
720                    visible_tabs.remove(0);
721                    total_width -= removed_width;
722                }
723
724                // Add current tab if there's now enough space
725                if total_width + tab_width <= available_width {
726                    tab_widths.push(tab_width as u16);
727                    visible_tabs.push(current_index);
728                }
729            }
730            break;
731        }
732    }
733
734    // Limit title width to at most 2/3 of the area
735    let max_title_width = (area.width * 2 / 3).min(title_width);
736
737    // Create a two-column layout: title column and tab column
738    let horizontal_layout = Layout::default()
739        .direction(Direction::Horizontal)
740        .constraints([Constraint::Length(max_title_width), Constraint::Min(0)])
741        .split(area);
742
743    let title_style = if is_editing {
744        Style::default().bg(Color::DarkGray).fg(Color::Gray)
745    } else {
746        Style::default().bg(Color::DarkGray).fg(Color::White)
747    };
748
749    let title_widget = Paragraph::new(title_content).style(title_style);
750
751    f.render_widget(title_widget, horizontal_layout[0]);
752
753    // Create constraints for tab layout
754    let mut tab_constraints = Vec::new();
755    for &width in &tab_widths {
756        tab_constraints.push(Constraint::Length(width));
757    }
758    tab_constraints.push(Constraint::Min(0)); // Filler space
759
760    let tab_layout = Layout::default()
761        .direction(Direction::Horizontal)
762        .constraints(tab_constraints)
763        .split(horizontal_layout[1]);
764
765    // Render each visible tab
766    for (layout_idx, &sheet_idx) in visible_tabs.iter().enumerate() {
767        if layout_idx >= tab_layout.len() - 1 {
768            break;
769        }
770
771        let name = &sheet_names[sheet_idx];
772        let is_current = sheet_idx == current_index;
773
774        let style = if is_editing {
775            if is_current {
776                Style::default().bg(Color::DarkGray).fg(Color::Gray)
777            } else {
778                Style::default().fg(Color::DarkGray)
779            }
780        } else if is_current {
781            Style::default().bg(Color::DarkGray).fg(Color::White)
782        } else {
783            Style::default()
784        };
785
786        let tab_widget = Paragraph::new(name.to_string())
787            .style(style)
788            .alignment(ratatui::layout::Alignment::Center);
789
790        f.render_widget(tab_widget, tab_layout[layout_idx]);
791    }
792
793    // Show indicator if not all tabs are visible
794    if visible_tabs.len() < sheet_names.len() {
795        let more_indicator = "...";
796        let indicator_style = Style::default().bg(Color::DarkGray).fg(Color::White);
797        let indicator_width = more_indicator.len() as u16;
798
799        // Position indicator at the right edge
800        let indicator_rect = Rect {
801            x: area.x + area.width - indicator_width,
802            y: area.y,
803            width: indicator_width,
804            height: 1,
805        };
806
807        let indicator_widget = Paragraph::new(more_indicator).style(indicator_style);
808        f.render_widget(indicator_widget, indicator_rect);
809    }
810}