ghostscope_ui/components/source_panel/
renderer.rs

1use crate::components::command_panel::FileCompletionCache;
2use crate::model::panel_state::{SourcePanelMode, SourcePanelState};
3use crate::ui::themes::UIThemes;
4use ratatui::{
5    layout::Rect,
6    style::{Color, Modifier, Style},
7    text::{Line, Span},
8    widgets::{Block, BorderType, Borders, List, ListItem, Paragraph},
9    Frame,
10};
11use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
12
13/// Handles source panel rendering
14pub struct SourceRenderer;
15
16impl SourceRenderer {
17    /// Render the source panel
18    pub fn render(
19        f: &mut Frame,
20        area: Rect,
21        state: &mut SourcePanelState,
22        cache: &FileCompletionCache,
23        is_focused: bool,
24    ) {
25        state.area_height = area.height;
26        state.area_width = area.width;
27
28        // Ensure both horizontal and vertical cursor are visible with actual panel dimensions
29        crate::components::source_panel::navigation::SourceNavigation::ensure_horizontal_cursor_visible(state, area.width);
30        crate::components::source_panel::navigation::SourceNavigation::ensure_cursor_visible(
31            state,
32            area.height,
33        );
34
35        // If in file search mode, render only the overlay
36        if state.mode == SourcePanelMode::FileSearch {
37            Self::render_file_search_overlay(f, area, state, cache);
38            return;
39        }
40
41        // Render normal source view
42        Self::render_source_content(f, area, state, is_focused);
43
44        // Render overlays based on mode
45        if is_focused {
46            match state.mode {
47                SourcePanelMode::Normal => {
48                    Self::render_number_buffer(f, area, state);
49                    Self::render_cursor(f, area, state);
50                }
51                SourcePanelMode::TextSearch => {
52                    Self::render_search_prompt(f, area, state);
53                }
54                SourcePanelMode::FileSearch => {
55                    // Already handled above
56                }
57            }
58        }
59    }
60
61    /// Render main source content
62    fn render_source_content(
63        f: &mut Frame,
64        area: Rect,
65        state: &SourcePanelState,
66        is_focused: bool,
67    ) {
68        let items: Vec<ListItem> = state
69            .content
70            .iter()
71            .enumerate()
72            .skip(state.scroll_offset)
73            .map(|(i, line)| {
74                let line_num = i + 1;
75                let is_current_line = i == state.cursor_line;
76
77                // Check trace status for this line
78                let is_enabled = state.traced_lines.contains(&line_num);
79                let is_disabled = state.disabled_lines.contains(&line_num);
80                let is_pending = state.pending_trace_line == Some(line_num);
81
82                let line_number_style = if is_enabled {
83                    // Green bold for enabled traces
84                    Style::default()
85                        .fg(Color::Green)
86                        .add_modifier(Modifier::BOLD)
87                } else if is_disabled {
88                    // Yellow bold for disabled traces
89                    Style::default()
90                        .fg(Color::Yellow)
91                        .add_modifier(Modifier::BOLD)
92                } else if is_pending {
93                    // Light yellow for pending trace
94                    Style::default()
95                        .fg(Color::LightYellow)
96                        .add_modifier(Modifier::BOLD)
97                } else if is_current_line && is_focused {
98                    Style::default().fg(Color::LightYellow).bg(Color::DarkGray)
99                } else {
100                    Style::default().fg(Color::DarkGray)
101                };
102
103                // Apply horizontal scrolling to the line content
104                let visible_line = if state.horizontal_scroll_offset > 0 {
105                    let chars: Vec<char> = line.chars().collect();
106                    if state.horizontal_scroll_offset < chars.len() {
107                        chars[state.horizontal_scroll_offset..].iter().collect()
108                    } else {
109                        String::new()
110                    }
111                } else {
112                    line.to_string()
113                };
114
115                // Calculate available width for content dynamically
116
117                // Pure vim-style display - no truncation, just show what fits
118                // Horizontal scrolling is already applied above
119                let display_line = visible_line;
120
121                // Apply syntax highlighting
122                let highlighted_spans = Self::highlight_line(&display_line, &state.language);
123
124                // Apply search highlighting overlay
125                let final_spans =
126                    Self::apply_search_overlay(&display_line, highlighted_spans, i, state);
127
128                let mut spans = vec![Span::styled(format!("{line_num:4} "), line_number_style)];
129                spans.extend(final_spans);
130
131                ListItem::new(Line::from(spans))
132            })
133            .collect();
134
135        let border_style = if is_focused {
136            UIThemes::panel_focused()
137        } else {
138            UIThemes::panel_unfocused()
139        };
140
141        let title = match &state.file_path {
142            Some(path) => format!("Source Code - {path}"),
143            None => "Source Code".to_string(),
144        };
145
146        let border_type = if is_focused {
147            BorderType::Thick
148        } else {
149            BorderType::Rounded
150        };
151
152        let list = List::new(items).block(
153            Block::default()
154                .borders(Borders::ALL)
155                .border_type(border_type)
156                .title(title)
157                .border_style(border_style),
158        );
159
160        f.render_widget(list, area);
161    }
162
163    /// Render file search overlay
164    fn render_file_search_overlay(
165        f: &mut Frame,
166        area: Rect,
167        state: &SourcePanelState,
168        cache: &FileCompletionCache,
169    ) {
170        // Clear the entire area
171        f.render_widget(ratatui::widgets::Clear, area);
172
173        // Render background
174        let background = Block::default()
175            .style(Style::default().bg(Color::Rgb(16, 16, 16)))
176            .borders(Borders::NONE);
177        f.render_widget(background, area);
178
179        // Calculate overlay dimensions
180        let overlay_height = 13u16.min(area.height);
181        let overlay_width = area.width.saturating_sub(10).max(40);
182        let overlay_area = Rect::new(
183            area.x + (area.width.saturating_sub(overlay_width)) / 2,
184            area.y + (area.height.saturating_sub(overlay_height)) / 2,
185            overlay_width,
186            overlay_height,
187        );
188
189        // Clear overlay area
190        f.render_widget(ratatui::widgets::Clear, overlay_area);
191
192        // Outer block
193        let block = Block::default()
194            .borders(Borders::ALL)
195            .border_type(BorderType::Thick)
196            .title("Open File")
197            .border_style(Style::default().fg(Color::Cyan))
198            .style(Style::default().bg(Color::Rgb(20, 20, 20)));
199        f.render_widget(block, overlay_area);
200
201        if overlay_area.width <= 2 || overlay_area.height <= 2 {
202            return;
203        }
204
205        let inner = Rect {
206            x: overlay_area.x + 1,
207            y: overlay_area.y + 1,
208            width: overlay_area.width - 2,
209            height: overlay_area.height - 2,
210        };
211
212        // Input line with cursor support
213        let prefix = "🔎 ";
214        Self::render_input_with_cursor(f, inner, state, prefix);
215
216        // Body: message or file list
217        if let Some(msg) = &state.file_search_message {
218            let msg_para = Paragraph::new(msg.clone()).style(
219                Style::default()
220                    .fg(if msg.starts_with('✗') {
221                        Color::Red
222                    } else {
223                        Color::DarkGray
224                    })
225                    .bg(Color::Rgb(30, 30, 30)),
226            );
227            if inner.height > 2 {
228                f.render_widget(
229                    msg_para,
230                    Rect::new(inner.x, inner.y + 1, inner.width, inner.height - 1),
231                );
232            }
233        } else {
234            Self::render_file_list(f, inner, state, cache);
235        }
236    }
237
238    /// Render file list in file search overlay
239    fn render_file_list(
240        f: &mut Frame,
241        area: Rect,
242        state: &SourcePanelState,
243        cache: &FileCompletionCache,
244    ) {
245        let mut items: Vec<ListItem> = Vec::new();
246        let start = state.file_search_scroll;
247        let end = (start + 10).min(state.file_search_filtered_indices.len());
248
249        let all_files = cache.get_all_files();
250        for idx in start..end {
251            let real_idx = state.file_search_filtered_indices[idx];
252            let path = &all_files[real_idx];
253
254            // Get file icon based on extension
255            let icon = Self::get_file_icon(path);
256
257            let is_selected = idx == state.file_search_selected;
258            let text_color = if is_selected {
259                Color::LightMagenta
260            } else {
261                Color::White
262            };
263
264            // Create display text with safe truncation
265            let full_text = format!("{icon} {path}");
266            let max_width = (area.width.saturating_sub(4)) as usize;
267            let display_text = Self::truncate_text(&full_text, max_width);
268
269            let line = Line::from(vec![Span::styled(
270                display_text,
271                Style::default().fg(text_color),
272            )]);
273            items.push(ListItem::new(line));
274        }
275
276        let list = List::new(items)
277            .block(Block::default().style(Style::default().bg(Color::Rgb(30, 30, 30))));
278        let list_area = Rect::new(
279            area.x,
280            area.y + 1,
281            area.width,
282            area.height.saturating_sub(1),
283        );
284        f.render_widget(list, list_area);
285    }
286
287    /// Render number buffer display
288    fn render_number_buffer(f: &mut Frame, area: Rect, state: &SourcePanelState) {
289        if state.number_buffer.is_empty() && !state.g_pressed {
290            return;
291        }
292
293        let mut display_text = String::new();
294        if !state.number_buffer.is_empty() {
295            display_text.push_str(&state.number_buffer);
296        }
297        if state.g_pressed {
298            display_text.push('g');
299        }
300
301        if display_text.is_empty() {
302            return;
303        }
304
305        let hint_text = if state.g_pressed && state.number_buffer.is_empty() {
306            "Press 'g' again for top"
307        } else if !state.number_buffer.is_empty() {
308            "Press 'G' to jump to line"
309        } else {
310            ""
311        };
312
313        let mut spans = Vec::new();
314        spans.push(Span::styled(
315            display_text.clone(),
316            Style::default().fg(Color::Green).bg(Color::Rgb(30, 30, 30)),
317        ));
318
319        if !hint_text.is_empty() {
320            spans.push(Span::styled(
321                format!(" ({hint_text})"),
322                Style::default().fg(Color::Cyan).bg(Color::Rgb(30, 30, 30)),
323            ));
324        }
325
326        let text = ratatui::text::Text::from(Line::from(spans));
327        let full_text = if !hint_text.is_empty() {
328            format!("{display_text} ({hint_text})")
329        } else {
330            display_text
331        };
332
333        let text_width = full_text.len() as u16;
334        let display_x = area.x + area.width.saturating_sub(text_width + 2);
335        let display_y = area.y + area.height.saturating_sub(1);
336
337        f.render_widget(
338            Paragraph::new(text).alignment(ratatui::layout::Alignment::Right),
339            Rect::new(display_x, display_y, text_width + 2, 1),
340        );
341    }
342
343    /// Render search prompt
344    fn render_search_prompt(f: &mut Frame, area: Rect, state: &SourcePanelState) {
345        let text = ratatui::text::Text::from(Line::from(vec![
346            Span::styled("/", Style::default().fg(Color::Yellow)),
347            Span::styled(&state.search_query, Style::default().fg(Color::White)),
348        ]));
349
350        let display_x = area.x + 1;
351        let display_y = area.y + area.height.saturating_sub(1);
352        let text_width = (1 + state.search_query.len()) as u16 + 1;
353
354        f.render_widget(
355            Paragraph::new(text),
356            Rect::new(display_x, display_y, text_width, 1),
357        );
358    }
359
360    /// Render cursor
361    fn render_cursor(f: &mut Frame, area: Rect, state: &SourcePanelState) {
362        if state.content.is_empty() {
363            return;
364        }
365
366        let cursor_y = area.y + 1 + (state.cursor_line.saturating_sub(state.scroll_offset)) as u16;
367        const LINE_NUMBER_WIDTH: u16 = 5;
368
369        // Calculate cursor X position considering horizontal scroll
370        // Only render cursor if it's actually within the horizontally visible area
371        if state.cursor_col >= state.horizontal_scroll_offset {
372            let visible_cursor_column = state.cursor_col - state.horizontal_scroll_offset;
373            let cursor_x = area.x + 1 + LINE_NUMBER_WIDTH + visible_cursor_column as u16;
374
375            // Calculate content area boundaries
376            let content_start_x = area.x + 1 + LINE_NUMBER_WIDTH;
377            let content_area_width = area.width.saturating_sub(LINE_NUMBER_WIDTH + 2); // 2 for borders
378            let content_end_x = content_start_x + content_area_width;
379
380            // Only render cursor if it's within the visible area (more permissive boundary check)
381            if cursor_y < area.y + area.height - 1
382                && cursor_x < content_end_x  // Allow cursor to be at the edge
383                && cursor_x >= content_start_x
384            {
385                f.render_widget(
386                    Block::default().style(crate::ui::themes::UIThemes::cursor_style()),
387                    Rect::new(cursor_x, cursor_y, 1, 1),
388                );
389            }
390        }
391    }
392
393    /// Improved syntax highlighting with proper comment support
394    fn highlight_line(line: &str, language: &str) -> Vec<Span<'static>> {
395        let mut spans = Vec::new();
396
397        // Check for single-line comments first
398        if let Some(comment_pos) = line.find("//") {
399            // Handle text before comment
400            if comment_pos > 0 {
401                spans.extend(Self::highlight_code(&line[..comment_pos], language));
402            }
403            // Handle comment (everything after //)
404            spans.push(Span::styled(
405                line[comment_pos..].to_string(),
406                Style::default().fg(Color::DarkGray),
407            ));
408            return spans;
409        }
410
411        // Check for multi-line comment markers
412        if line.trim_start().starts_with("/*")
413            || line.contains("*/")
414            || line.trim_start().starts_with("*")
415        {
416            spans.push(Span::styled(
417                line.to_string(),
418                Style::default().fg(Color::DarkGray),
419            ));
420            return spans;
421        }
422
423        // Regular code highlighting
424        spans.extend(Self::highlight_code(line, language));
425        spans
426    }
427
428    /// Highlight code without comments
429    fn highlight_code(text: &str, language: &str) -> Vec<Span<'static>> {
430        // Check for preprocessor directives first (for C/C++)
431        if (language == "c" || language == "cpp") && text.trim_start().starts_with('#') {
432            return vec![Span::styled(
433                text.to_string(),
434                Style::default().fg(Color::LightRed),
435            )];
436        }
437
438        let mut spans = Vec::new();
439        let mut current_pos = 0;
440        let mut in_string = false;
441        let mut string_char = '\0';
442        let chars: Vec<char> = text.chars().collect();
443        let mut i = 0;
444
445        while i < chars.len() {
446            let ch = chars[i];
447
448            // Handle strings
449            if ch == '"' || ch == '\'' {
450                if !in_string {
451                    // Add text before string
452                    if i > 0 {
453                        let before_string = &text[current_pos..Self::char_to_byte_pos(&chars, i)];
454                        spans.extend(Self::highlight_words(before_string, language));
455                    }
456                    // Start string
457                    in_string = true;
458                    string_char = ch;
459                    current_pos = Self::char_to_byte_pos(&chars, i);
460                } else if ch == string_char {
461                    // End string
462                    spans.push(Span::styled(
463                        text[current_pos..Self::char_to_byte_pos(&chars, i + 1)].to_string(),
464                        Style::default().fg(Color::Yellow),
465                    ));
466                    in_string = false;
467                    current_pos = Self::char_to_byte_pos(&chars, i + 1);
468                }
469            }
470
471            i += 1;
472        }
473
474        // Handle remaining text
475        if current_pos < text.len() && !in_string {
476            spans.extend(Self::highlight_words(&text[current_pos..], language));
477        } else if in_string {
478            // Unclosed string
479            spans.push(Span::styled(
480                text[current_pos..].to_string(),
481                Style::default().fg(Color::Yellow),
482            ));
483        }
484
485        if spans.is_empty() {
486            spans.push(Span::styled(text.to_string(), Style::default()));
487        }
488
489        spans
490    }
491
492    /// Convert character position to byte position
493    fn char_to_byte_pos(chars: &[char], char_pos: usize) -> usize {
494        chars.iter().take(char_pos).map(|c| c.len_utf8()).sum()
495    }
496
497    /// Highlight words (keywords, types, numbers)
498    fn highlight_words(text: &str, language: &str) -> Vec<Span<'static>> {
499        let mut spans = Vec::new();
500        let mut current_word = String::new();
501        let mut current_text = String::new();
502
503        for ch in text.chars() {
504            if ch.is_alphanumeric() || ch == '_' {
505                if !current_text.is_empty() {
506                    spans.push(Span::styled(current_text.clone(), Style::default()));
507                    current_text.clear();
508                }
509                current_word.push(ch);
510            } else {
511                if !current_word.is_empty() {
512                    let style = Self::get_word_style(&current_word, language);
513                    spans.push(Span::styled(current_word.clone(), style));
514                    current_word.clear();
515                }
516                current_text.push(ch);
517            }
518        }
519
520        // Handle remaining word or text
521        if !current_word.is_empty() {
522            let style = Self::get_word_style(&current_word, language);
523            spans.push(Span::styled(current_word, style));
524        }
525        if !current_text.is_empty() {
526            spans.push(Span::styled(current_text, Style::default()));
527        }
528
529        spans
530    }
531
532    /// Get style for a word based on its type
533    fn get_word_style(word: &str, language: &str) -> Style {
534        if word.chars().all(|c| c.is_ascii_digit()) {
535            // Numbers
536            Style::default().fg(Color::Magenta)
537        } else if Self::is_keyword(word, language) {
538            // Keywords
539            Style::default().fg(Color::Blue)
540        } else if Self::is_type(word, language) {
541            // Types
542            Style::default().fg(Color::Cyan)
543        } else {
544            // Normal text
545            Style::default()
546        }
547    }
548
549    /// Check if a word is a keyword
550    fn is_keyword(word: &str, language: &str) -> bool {
551        match language {
552            "c" => matches!(
553                word,
554                "auto"
555                    | "break"
556                    | "case"
557                    | "char"
558                    | "const"
559                    | "continue"
560                    | "default"
561                    | "do"
562                    | "double"
563                    | "else"
564                    | "enum"
565                    | "extern"
566                    | "float"
567                    | "for"
568                    | "goto"
569                    | "if"
570                    | "int"
571                    | "long"
572                    | "register"
573                    | "return"
574                    | "short"
575                    | "signed"
576                    | "sizeof"
577                    | "static"
578                    | "struct"
579                    | "switch"
580                    | "typedef"
581                    | "union"
582                    | "unsigned"
583                    | "void"
584                    | "volatile"
585                    | "while"
586            ),
587            "cpp" => {
588                Self::is_keyword(word, "c")
589                    || matches!(
590                        word,
591                        "class"
592                            | "namespace"
593                            | "template"
594                            | "typename"
595                            | "public"
596                            | "private"
597                            | "protected"
598                            | "virtual"
599                            | "override"
600                            | "final"
601                            | "explicit"
602                            | "friend"
603                            | "inline"
604                            | "mutable"
605                            | "new"
606                            | "delete"
607                            | "this"
608                            | "operator"
609                            | "throw"
610                            | "try"
611                            | "catch"
612                            | "bool"
613                            | "true"
614                            | "false"
615                    )
616            }
617            "rust" => matches!(
618                word,
619                "as" | "break"
620                    | "const"
621                    | "continue"
622                    | "crate"
623                    | "else"
624                    | "enum"
625                    | "extern"
626                    | "false"
627                    | "fn"
628                    | "for"
629                    | "if"
630                    | "impl"
631                    | "in"
632                    | "let"
633                    | "loop"
634                    | "match"
635                    | "mod"
636                    | "move"
637                    | "mut"
638                    | "pub"
639                    | "ref"
640                    | "return"
641                    | "self"
642                    | "Self"
643                    | "static"
644                    | "struct"
645                    | "super"
646                    | "trait"
647                    | "true"
648                    | "type"
649                    | "unsafe"
650                    | "use"
651                    | "where"
652                    | "while"
653                    | "async"
654                    | "await"
655                    | "dyn"
656            ),
657            _ => false,
658        }
659    }
660
661    /// Check if a word is a type
662    fn is_type(word: &str, language: &str) -> bool {
663        match language {
664            "c" | "cpp" => matches!(
665                word,
666                "int"
667                    | "char"
668                    | "float"
669                    | "double"
670                    | "void"
671                    | "short"
672                    | "long"
673                    | "unsigned"
674                    | "signed"
675                    | "bool"
676                    | "size_t"
677                    | "uint8_t"
678                    | "uint16_t"
679                    | "uint32_t"
680                    | "uint64_t"
681                    | "int8_t"
682                    | "int16_t"
683                    | "int32_t"
684                    | "int64_t"
685            ),
686            "rust" => matches!(
687                word,
688                "i8" | "i16"
689                    | "i32"
690                    | "i64"
691                    | "i128"
692                    | "isize"
693                    | "u8"
694                    | "u16"
695                    | "u32"
696                    | "u64"
697                    | "u128"
698                    | "usize"
699                    | "f32"
700                    | "f64"
701                    | "bool"
702                    | "char"
703                    | "str"
704                    | "String"
705                    | "Vec"
706                    | "Option"
707                    | "Result"
708            ),
709            _ => false,
710        }
711    }
712
713    /// Apply search highlighting overlay
714    fn apply_search_overlay(
715        visible_line: &str,
716        spans: Vec<Span<'static>>,
717        line_index: usize,
718        state: &SourcePanelState,
719    ) -> Vec<Span<'static>> {
720        if state.search_query.is_empty() || state.search_matches.is_empty() {
721            return spans
722                .into_iter()
723                .map(|s| Span::styled(s.content.to_string(), s.style))
724                .collect();
725        }
726
727        // Find matches for this line in visible coordinates
728        let h_off = state.horizontal_scroll_offset;
729        let ranges: Vec<(usize, usize)> = state
730            .search_matches
731            .iter()
732            .filter_map(|(li, s, e)| {
733                if *li != line_index {
734                    return None;
735                }
736                if *e <= h_off || *s >= h_off + visible_line.len() {
737                    return None;
738                }
739                let vis_start = s.saturating_sub(h_off);
740                let vis_end = e.saturating_sub(h_off);
741                Some((vis_start, vis_end))
742            })
743            .collect();
744
745        if ranges.is_empty() {
746            return spans
747                .into_iter()
748                .map(|s| Span::styled(s.content.to_string(), s.style))
749                .collect();
750        }
751
752        // Apply highlighting (simplified implementation)
753        let mut result: Vec<Span<'static>> = Vec::new();
754        let mut pos = 0usize;
755
756        for span in spans {
757            let text = span.content.clone();
758            let base_style = span.style;
759            let mut cursor = 0usize;
760
761            while cursor < text.len() {
762                let mut next_break = text.len() - cursor;
763                let mut highlight_now = false;
764
765                for (rs, re) in &ranges {
766                    if pos >= *re || pos + next_break <= *rs {
767                        continue;
768                    }
769                    if pos < *rs {
770                        next_break = (*rs - pos).min(next_break);
771                        highlight_now = false;
772                    } else {
773                        next_break = (*re - pos).min(next_break);
774                        highlight_now = true;
775                    }
776                }
777
778                let end_cursor = cursor + next_break;
779                let slice = &text[cursor..end_cursor];
780                let style = if highlight_now {
781                    Style::default().fg(Color::LightMagenta)
782                } else {
783                    base_style
784                };
785
786                result.push(Span::styled(slice.to_string(), style));
787                pos += next_break;
788                cursor = end_cursor;
789            }
790        }
791
792        result
793    }
794
795    /// Get file icon based on extension
796    fn get_file_icon(path: &str) -> &'static str {
797        match std::path::Path::new(path)
798            .extension()
799            .and_then(|s| s.to_str())
800            .map(|s| s.to_ascii_lowercase())
801        {
802            Some(ref e) if ["h", "hpp", "hh", "hxx"].contains(&e.as_str()) => "📑",
803            Some(ref e) if ["c", "cc", "cpp", "cxx"].contains(&e.as_str()) => "📝",
804            Some(ref e) if e == "rs" => "🦀",
805            Some(ref e) if ["s", "asm"].contains(&e.as_str()) => "🛠️",
806            _ => "📄",
807        }
808    }
809
810    /// Safely truncate text to fit width (vim-style - just cut off, no dots)
811    fn truncate_text(text: &str, max_width: usize) -> String {
812        if text.width() <= max_width {
813            text.to_string()
814        } else {
815            let mut truncated = String::new();
816            let mut current_width = 0;
817
818            for ch in text.chars() {
819                let char_width = ch.width().unwrap_or(1);
820                if current_width + char_width > max_width {
821                    break;
822                }
823                truncated.push(ch);
824                current_width += char_width;
825            }
826            truncated
827        }
828    }
829
830    /// Render input line with proper cursor positioning
831    fn render_input_with_cursor(f: &mut Frame, area: Rect, state: &SourcePanelState, prefix: &str) {
832        let chars: Vec<char> = state.file_search_query.chars().collect();
833        let cursor_pos = state.file_search_cursor_pos;
834
835        // Build the input line with cursor
836        let mut spans = vec![Span::styled(
837            prefix.to_string(),
838            Style::default().fg(Color::Cyan),
839        )];
840
841        if chars.is_empty() {
842            // Empty input, show cursor as a space
843            spans.push(Span::styled(" ".to_string(), UIThemes::cursor_style()));
844        } else if cursor_pos >= chars.len() {
845            // Cursor at end
846            spans.push(Span::styled(
847                state.file_search_query.clone(),
848                Style::default().fg(Color::White),
849            ));
850            spans.push(Span::styled(" ".to_string(), UIThemes::cursor_style()));
851        } else {
852            // Cursor in middle of text
853            let before_cursor: String = chars[..cursor_pos].iter().collect();
854            let at_cursor = chars[cursor_pos];
855            let after_cursor: String = chars[cursor_pos + 1..].iter().collect();
856
857            if !before_cursor.is_empty() {
858                spans.push(Span::styled(
859                    before_cursor,
860                    Style::default().fg(Color::White),
861                ));
862            }
863
864            spans.push(Span::styled(
865                at_cursor.to_string(),
866                UIThemes::cursor_style(),
867            ));
868
869            if !after_cursor.is_empty() {
870                spans.push(Span::styled(
871                    after_cursor,
872                    Style::default().fg(Color::White),
873                ));
874            }
875        }
876
877        let input_line = Line::from(spans);
878        let input_para =
879            Paragraph::new(input_line).style(Style::default().bg(Color::Rgb(30, 30, 30)));
880        f.render_widget(input_para, Rect::new(area.x, area.y, area.width, 1));
881    }
882}