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
13pub struct SourceRenderer;
15
16impl SourceRenderer {
17 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 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 state.mode == SourcePanelMode::FileSearch {
37 Self::render_file_search_overlay(f, area, state, cache);
38 return;
39 }
40
41 Self::render_source_content(f, area, state, is_focused);
43
44 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 }
57 }
58 }
59 }
60
61 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 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 Style::default()
85 .fg(Color::Green)
86 .add_modifier(Modifier::BOLD)
87 } else if is_disabled {
88 Style::default()
90 .fg(Color::Yellow)
91 .add_modifier(Modifier::BOLD)
92 } else if is_pending {
93 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 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 let display_line = visible_line;
120
121 let highlighted_spans = Self::highlight_line(&display_line, &state.language);
123
124 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 fn render_file_search_overlay(
165 f: &mut Frame,
166 area: Rect,
167 state: &SourcePanelState,
168 cache: &FileCompletionCache,
169 ) {
170 f.render_widget(ratatui::widgets::Clear, area);
172
173 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 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 f.render_widget(ratatui::widgets::Clear, overlay_area);
191
192 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 let prefix = "🔎 ";
214 Self::render_input_with_cursor(f, inner, state, prefix);
215
216 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 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 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 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 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 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 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 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 let content_start_x = area.x + 1 + LINE_NUMBER_WIDTH;
377 let content_area_width = area.width.saturating_sub(LINE_NUMBER_WIDTH + 2); let content_end_x = content_start_x + content_area_width;
379
380 if cursor_y < area.y + area.height - 1
382 && cursor_x < content_end_x && 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 fn highlight_line(line: &str, language: &str) -> Vec<Span<'static>> {
395 let mut spans = Vec::new();
396
397 if let Some(comment_pos) = line.find("//") {
399 if comment_pos > 0 {
401 spans.extend(Self::highlight_code(&line[..comment_pos], language));
402 }
403 spans.push(Span::styled(
405 line[comment_pos..].to_string(),
406 Style::default().fg(Color::DarkGray),
407 ));
408 return spans;
409 }
410
411 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 spans.extend(Self::highlight_code(line, language));
425 spans
426 }
427
428 fn highlight_code(text: &str, language: &str) -> Vec<Span<'static>> {
430 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 if ch == '"' || ch == '\'' {
450 if !in_string {
451 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 in_string = true;
458 string_char = ch;
459 current_pos = Self::char_to_byte_pos(&chars, i);
460 } else if ch == string_char {
461 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 if current_pos < text.len() && !in_string {
476 spans.extend(Self::highlight_words(&text[current_pos..], language));
477 } else if in_string {
478 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 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 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(¤t_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 if !current_word.is_empty() {
522 let style = Self::get_word_style(¤t_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 fn get_word_style(word: &str, language: &str) -> Style {
534 if word.chars().all(|c| c.is_ascii_digit()) {
535 Style::default().fg(Color::Magenta)
537 } else if Self::is_keyword(word, language) {
538 Style::default().fg(Color::Blue)
540 } else if Self::is_type(word, language) {
541 Style::default().fg(Color::Cyan)
543 } else {
544 Style::default()
546 }
547 }
548
549 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 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 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 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 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 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 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 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 let mut spans = vec![Span::styled(
837 prefix.to_string(),
838 Style::default().fg(Color::Cyan),
839 )];
840
841 if chars.is_empty() {
842 spans.push(Span::styled(" ".to_string(), UIThemes::cursor_style()));
844 } else if cursor_pos >= chars.len() {
845 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 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}