1use super::syntax_highlighter;
2use crate::action::ResponseType;
3use crate::model::panel_state::{CommandPanelState, InteractionMode, LineType};
4use crate::ui::themes::UIThemes;
5use ratatui::{
6 layout::Rect,
7 style::{Color, Modifier, Style},
8 text::{Line, Span},
9 widgets::{Block, BorderType, Borders, Paragraph},
10 Frame,
11};
12use unicode_width::UnicodeWidthChar;
13
14#[derive(Debug)]
16pub struct OptimizedRenderer {
17 scroll_offset: usize,
19 visible_lines: usize,
20}
21
22impl OptimizedRenderer {
23 pub fn new() -> Self {
24 Self {
25 scroll_offset: 0,
26 visible_lines: 0,
27 }
28 }
29
30 pub fn mark_pending_updates(&mut self) {
32 }
34
35 pub fn render(
37 &mut self,
38 f: &mut Frame,
39 area: Rect,
40 state: &CommandPanelState,
41 is_focused: bool,
42 ) {
43 self.update_viewport(area);
45
46 self.render_border(f, area, is_focused, state);
48 self.render_content(f, area, state);
49 }
50
51 fn update_viewport(&mut self, area: Rect) {
53 let height = area.height.saturating_sub(2);
54 self.visible_lines = height as usize;
55 }
56
57 fn render_border(
59 &self,
60 f: &mut Frame,
61 area: Rect,
62 is_focused: bool,
63 state: &CommandPanelState,
64 ) {
65 let border_style = if !is_focused {
66 Style::default().fg(Color::White)
67 } else {
68 match state.input_state {
69 crate::model::panel_state::InputState::WaitingResponse { .. } => {
70 Style::default().fg(Color::Yellow)
71 }
72 _ => match state.mode {
73 InteractionMode::Input => Style::default().fg(Color::Green),
74 InteractionMode::Command => Style::default().fg(Color::Cyan),
75 InteractionMode::ScriptEditor => Style::default().fg(Color::Green),
76 },
77 }
78 };
79
80 let title = match state.input_state {
81 crate::model::panel_state::InputState::WaitingResponse { .. } => {
82 "Interactive Command (waiting for response...)".to_string()
83 }
84 _ => match state.mode {
85 InteractionMode::Input => "Interactive Command (input mode)".to_string(),
86 InteractionMode::Command => "Interactive Command (command mode)".to_string(),
87 InteractionMode::ScriptEditor => "Interactive Command (script mode)".to_string(),
88 },
89 };
90
91 let block = Block::default()
92 .title(title)
93 .borders(Borders::ALL)
94 .border_type(if is_focused {
95 BorderType::Thick
96 } else {
97 BorderType::Plain
98 })
99 .border_style(border_style);
100
101 f.render_widget(block, area);
102 }
103
104 fn render_content(&self, f: &mut Frame, area: Rect, state: &CommandPanelState) {
106 let inner_area = Rect::new(
107 area.x + 1,
108 area.y + 1,
109 area.width.saturating_sub(2),
110 area.height.saturating_sub(2),
111 );
112
113 let width = inner_area.width;
114 let mut lines = Vec::new();
115
116 for static_line in &state.static_lines {
118 match static_line.line_type {
119 LineType::Welcome => {
120 if let Some(ref styled_content) = static_line.styled_content {
121 let wrapped_lines = self.wrap_styled_line(styled_content, width as usize);
122 lines.extend(wrapped_lines);
123 } else {
124 let wrapped_lines = self.wrap_text(&static_line.content, width);
125 for wrapped_line in wrapped_lines {
126 lines.push(self.create_fallback_welcome_line(&wrapped_line));
127 }
128 }
129 }
130 LineType::Response => {
131 if let Some(ref styled_content) = static_line.styled_content {
133 let wrapped_lines = self.wrap_styled_line(styled_content, width as usize);
134 lines.extend(wrapped_lines);
135 } else {
136 let wrapped_lines = self.wrap_text(&static_line.content, width);
138 let has_ansi = static_line.content.contains("\x1b[");
140 for wrapped_line in wrapped_lines {
141 let response_type = if has_ansi {
142 None } else {
144 static_line.response_type
145 };
146 lines.push(self.create_response_line(&wrapped_line, response_type));
147 }
148 }
149 }
150 LineType::Command => {
151 let wrapped_lines = self.wrap_text(&static_line.content, width);
152 for wrapped_line in wrapped_lines {
153 lines.push(self.create_command_line(&wrapped_line));
154 }
155 }
156 LineType::CurrentInput => {
157 let wrapped_lines = self.wrap_text(&static_line.content, width);
158 for wrapped_line in wrapped_lines {
159 lines.push(Line::from(Span::styled(
160 wrapped_line,
161 Style::default().fg(Color::White),
162 )));
163 }
164 }
165 }
166 }
167
168 match state.mode {
174 InteractionMode::ScriptEditor => {
175 if let Some(ref script_cache) = state.script_cache {
176 self.render_script_editor(script_cache, width, &mut lines);
177 }
178 }
179 _ => {
180 if matches!(
181 state.input_state,
182 crate::model::panel_state::InputState::Ready
183 ) {
184 if state.is_in_history_search() {
185 self.render_history_search(state, width, &mut lines);
186 } else {
187 self.render_normal_input(state, width, &mut lines);
188 }
189 }
190 }
191 }
192
193 let total_lines = lines.len();
195 let visible_count = inner_area.height as usize;
196
197 let (start_line, cursor_line_in_viewport) =
198 if matches!(state.mode, InteractionMode::Command) || state.is_in_history_search() {
199 let cursor_line = if state.is_in_history_search() {
200 total_lines.saturating_sub(1)
201 } else {
202 state.command_cursor_line
203 };
204
205 let mut start = total_lines.saturating_sub(visible_count);
206
207 if cursor_line < start {
208 start = cursor_line;
209 } else if cursor_line >= start + visible_count {
210 start = cursor_line.saturating_sub(visible_count - 1);
211 }
212
213 (start, Some(cursor_line.saturating_sub(start)))
214 } else {
215 let start = total_lines.saturating_sub(visible_count);
216 (start, None)
217 };
218
219 let mut visible_lines: Vec<Line> = lines
220 .into_iter()
221 .skip(start_line)
222 .take(visible_count)
223 .collect();
224
225 if let Some(cursor_line_idx) = cursor_line_in_viewport {
227 if cursor_line_idx < visible_lines.len()
228 && matches!(state.mode, InteractionMode::Command)
229 {
230 self.add_command_cursor(
231 &mut visible_lines[cursor_line_idx],
232 state.command_cursor_column,
233 );
234 }
235 }
236
237 let paragraph = Paragraph::new(visible_lines);
238 f.render_widget(paragraph, inner_area);
239 }
240
241 fn render_script_editor(
243 &self,
244 script_cache: &crate::model::panel_state::ScriptCache,
245 width: u16,
246 lines: &mut Vec<Line<'static>>,
247 ) {
248 let header = format!(
250 "🔨 Entering script mode for target: {}",
251 script_cache.target
252 );
253 let header_display = if header.len() > width as usize {
255 format!("{}...", &header[..width as usize - 3])
256 } else {
257 header
258 };
259 lines.push(Line::from(Span::styled(
260 header_display,
261 Style::default().fg(Color::Cyan),
262 )));
263
264 let separator_width = std::cmp::min(width as usize, 60).saturating_sub(4); let separator = "─".repeat(separator_width);
267 lines.push(Line::from(Span::styled(
268 separator,
269 Style::default().fg(Color::Cyan),
270 )));
271
272 let prompt_text = "Script Editor (Ctrl+s to submit, Esc to cancel):";
274 let wrapped_prompts = self.wrap_text(prompt_text, width);
275 for wrapped_prompt in wrapped_prompts {
276 lines.push(Line::from(Span::styled(
277 wrapped_prompt,
278 Style::default()
279 .fg(Color::Cyan)
280 .add_modifier(Modifier::BOLD),
281 )));
282 }
283
284 lines.push(Line::from(""));
286
287 for (line_idx, line_content) in script_cache.lines.iter().enumerate() {
289 let line_number = format!("{:3} │ ", line_idx + 1);
290 let is_cursor_line = line_idx == script_cache.cursor_line;
291
292 let line_number_width = line_number.chars().count();
294 let available_width = width as usize - line_number_width;
295
296 if line_content.chars().count() > available_width {
297 let wrapped_lines = self.wrap_text(line_content, width - line_number_width as u16);
299 for (wrap_idx, wrapped_content) in wrapped_lines.iter().enumerate() {
300 if wrap_idx == 0 {
301 if is_cursor_line {
303 lines.push(self.create_script_line_with_cursor(
304 &line_number,
305 wrapped_content,
306 script_cache.cursor_col,
307 ));
308 } else {
309 lines.push(
310 self.create_highlighted_script_line(&line_number, wrapped_content),
311 );
312 }
313 } else {
314 let continuation_prefix = " ".repeat(line_number_width);
316 if is_cursor_line
317 && script_cache.cursor_col >= wrapped_content.chars().count()
318 {
319 let cursor_in_continuation =
321 script_cache.cursor_col - wrapped_content.chars().count();
322 lines.push(self.create_script_line_with_cursor(
323 &continuation_prefix,
324 wrapped_content,
325 cursor_in_continuation,
326 ));
327 } else {
328 lines.push(self.create_highlighted_script_line(
329 &continuation_prefix,
330 wrapped_content,
331 ));
332 }
333 }
334 }
335 } else {
336 if is_cursor_line {
338 lines.push(self.create_script_line_with_cursor(
339 &line_number,
340 line_content,
341 script_cache.cursor_col,
342 ));
343 } else {
344 lines.push(self.create_highlighted_script_line(&line_number, line_content));
345 }
346 }
347 }
348 }
349
350 fn render_history_search(
352 &self,
353 state: &CommandPanelState,
354 width: u16,
355 lines: &mut Vec<Line<'static>>,
356 ) {
357 let search_query = state.get_history_search_query();
358
359 if let Some(matched_command) = state
360 .history_search
361 .current_match(&state.command_history_manager)
362 {
363 let prompt_text = format!("(reverse-i-search)`{search_query}': ");
365 let full_content = format!("{prompt_text}{matched_command}");
366
367 if full_content.chars().count() > width as usize {
368 let wrapped_lines = self.wrap_text(&full_content, width);
370 let cursor_pos = prompt_text.chars().count() + search_query.len();
371
372 let mut char_count = 0;
373 for line in wrapped_lines.iter() {
374 let line_char_count = line.chars().count();
375 let line_end = char_count + line_char_count;
376
377 if cursor_pos >= char_count && cursor_pos < line_end {
378 let cursor_in_line = cursor_pos - char_count;
380 lines.push(self.create_history_search_line_with_cursor(
381 line,
382 &prompt_text,
383 cursor_in_line,
384 ));
385 } else {
386 lines.push(
387 self.create_history_search_line_without_cursor(line, &prompt_text),
388 );
389 }
390 char_count = line_end;
391 }
392 } else {
393 let cursor_pos = search_query.len();
395 lines.push(self.create_simple_history_search_line(
396 &prompt_text,
397 matched_command,
398 cursor_pos,
399 true,
400 ));
401 }
402 } else if search_query.is_empty() {
403 let prompt_text = "(reverse-i-search)`': ";
405
406 if prompt_text.chars().count() > width as usize {
407 let wrapped_lines = self.wrap_text(prompt_text, width);
409 for wrapped_line in wrapped_lines {
410 lines.push(Line::from(Span::styled(
411 wrapped_line,
412 Style::default().fg(Color::Cyan),
413 )));
414 }
415 } else {
416 lines.push(Line::from(Span::styled(
418 prompt_text,
419 Style::default().fg(Color::Cyan),
420 )));
421 }
422 } else {
423 let failed_prompt = format!("(failed reverse-i-search)`{search_query}': ");
425
426 if failed_prompt.chars().count() > width as usize {
427 let wrapped_lines = self.wrap_text(&failed_prompt, width);
429 for wrapped_line in wrapped_lines {
430 lines.push(Line::from(Span::styled(
431 wrapped_line,
432 Style::default().fg(Color::Red),
433 )));
434 }
435 } else {
436 lines.push(Line::from(Span::styled(
438 failed_prompt,
439 Style::default().fg(Color::Red),
440 )));
441 }
442 }
443 }
444
445 fn render_normal_input(
447 &self,
448 state: &CommandPanelState,
449 width: u16,
450 lines: &mut Vec<Line<'static>>,
451 ) {
452 let prompt = "(ghostscope) ";
453 let input_text = state.get_display_text();
454 let cursor_pos = state.get_display_cursor_position();
455
456 let suggestion_text = state.get_suggestion_text().unwrap_or_default();
458 let full_content_with_suggestion = format!("{prompt}{input_text}{suggestion_text}");
459 let base_content = format!("{prompt}{input_text}");
460
461 if full_content_with_suggestion.chars().count() > width as usize {
462 tracing::debug!(
464 "Wrapping text: suggestion_len={}, input_len={}, total_len={}, width={}",
465 suggestion_text.len(),
466 input_text.len(),
467 full_content_with_suggestion.len(),
468 width
469 );
470 let base_wrapped = self.wrap_text(&base_content, width);
472 let last_line_len = base_wrapped
473 .last()
474 .map(|line| line.chars().count())
475 .unwrap_or(0);
476 let suggestion_fits_in_last_line =
477 last_line_len + suggestion_text.chars().count() <= width as usize;
478
479 let wrapped_lines = if suggestion_fits_in_last_line {
480 base_wrapped
482 } else {
483 let full_wrapped = self.wrap_text(&full_content_with_suggestion, width);
485 full_wrapped
486 .into_iter()
487 .map(|line| {
488 if line.ends_with(&suggestion_text) {
490 line[..line.len() - suggestion_text.len()].to_string()
491 } else {
492 line
493 }
494 })
495 .collect()
496 };
497 tracing::debug!(
498 "Wrapped into {} lines: {:?}",
499 wrapped_lines.len(),
500 wrapped_lines
501 );
502 let cursor_pos_with_prompt = cursor_pos + prompt.chars().count();
503
504 let mut char_count = 0;
505 for (line_idx, line) in wrapped_lines.iter().enumerate() {
506 let line_char_count = line.chars().count();
507 let line_end = char_count + line_char_count;
508
509 let is_last_line = line_idx == wrapped_lines.len() - 1;
510 let cursor_in_range = cursor_pos_with_prompt >= char_count
511 && (cursor_pos_with_prompt < line_end
512 || (cursor_pos_with_prompt == line_end && is_last_line));
513
514 if cursor_in_range {
515 let cursor_in_line = cursor_pos_with_prompt - char_count;
516 lines.push(self.create_input_line_with_cursor_wrapped(
517 line,
518 prompt,
519 cursor_in_line,
520 line_idx == 0,
521 is_last_line,
522 state,
523 ));
524 } else {
525 lines.push(self.create_input_line_without_cursor(line, prompt, line_idx == 0));
526 }
527 char_count = line_end;
528 }
529 } else {
530 lines.push(self.create_input_line(prompt, input_text, cursor_pos, state));
532 }
533 }
534
535 fn create_simple_history_search_line(
537 &self,
538 prompt_text: &str,
539 matched_command: &str,
540 cursor_pos: usize,
541 show_cursor: bool,
542 ) -> Line<'static> {
543 let mut spans = vec![Span::styled(
544 prompt_text.to_string(),
545 Style::default().fg(Color::Cyan),
546 )];
547
548 if show_cursor {
549 self.add_text_with_cursor(&mut spans, matched_command, cursor_pos);
550 } else {
551 spans.push(Span::styled(matched_command.to_string(), Style::default()));
552 }
553
554 Line::from(spans)
555 }
556
557 fn create_history_search_line_with_cursor(
559 &self,
560 line: &str,
561 prompt_text: &str,
562 cursor_pos: usize,
563 ) -> Line<'static> {
564 let chars: Vec<char> = line.chars().collect();
565 let mut spans = Vec::new();
566 let prompt_len = prompt_text.chars().count();
567
568 if prompt_len > 0 && prompt_len <= chars.len() {
569 let prompt_part: String = chars[..prompt_len].iter().collect();
571 spans.push(Span::styled(prompt_part, Style::default().fg(Color::Cyan)));
572
573 let text_part: String = chars[prompt_len..].iter().collect();
574 let cursor_in_text = cursor_pos.saturating_sub(prompt_len);
575 self.add_text_with_cursor(&mut spans, &text_part, cursor_in_text);
576 } else {
577 self.add_text_with_cursor(&mut spans, line, cursor_pos);
579 }
580
581 Line::from(spans)
582 }
583
584 fn create_history_search_line_without_cursor(
586 &self,
587 line: &str,
588 prompt_text: &str,
589 ) -> Line<'static> {
590 let chars: Vec<char> = line.chars().collect();
591 let mut spans = Vec::new();
592 let prompt_len = prompt_text.chars().count();
593
594 if prompt_len > 0 && prompt_len <= chars.len() {
595 let prompt_part: String = chars[..prompt_len].iter().collect();
596 spans.push(Span::styled(prompt_part, Style::default().fg(Color::Cyan)));
597
598 let text_part: String = chars[prompt_len..].iter().collect();
599 spans.push(Span::styled(text_part, Style::default()));
600 } else {
601 spans.push(Span::styled(line.to_string(), Style::default()));
602 }
603
604 Line::from(spans)
605 }
606
607 fn create_input_line_with_cursor_wrapped(
609 &self,
610 line: &str,
611 prompt: &str,
612 cursor_pos: usize,
613 is_first_line: bool,
614 is_last_line: bool,
615 state: &CommandPanelState,
616 ) -> Line<'static> {
617 let chars: Vec<char> = line.chars().collect();
618 let mut spans = Vec::new();
619 let prompt_len = if is_first_line {
620 prompt.chars().count()
621 } else {
622 0
623 };
624
625 if is_first_line && prompt_len <= chars.len() {
626 let prompt_part: String = chars[..prompt_len].iter().collect();
627 spans.push(Span::styled(
628 prompt_part,
629 Style::default().fg(Color::Magenta),
630 ));
631
632 let text_part: String = chars[prompt_len..].iter().collect();
633 let cursor_in_text = cursor_pos.saturating_sub(prompt_len);
634
635 if is_last_line && cursor_in_text >= text_part.chars().count() {
637 tracing::debug!(
639 "Rendering with suggestion: is_last_line={}, cursor_in_text={}, text_part='{}'",
640 is_last_line,
641 cursor_in_text,
642 text_part
643 );
644 self.add_text_with_cursor_and_suggestion(
645 &mut spans,
646 &text_part,
647 cursor_in_text,
648 state,
649 );
650 } else {
651 tracing::debug!("Rendering without suggestion: is_last_line={}, cursor_in_text={}, text_part='{}'",
653 is_last_line, cursor_in_text, text_part);
654 self.add_text_with_cursor(&mut spans, &text_part, cursor_in_text);
655 }
656 } else {
657 if is_last_line && cursor_pos >= line.chars().count() {
659 self.add_text_with_cursor_and_suggestion(&mut spans, line, cursor_pos, state);
661 } else {
662 self.add_text_with_cursor(&mut spans, line, cursor_pos);
664 }
665 }
666
667 Line::from(spans)
668 }
669
670 fn create_input_line_without_cursor(
672 &self,
673 line: &str,
674 prompt: &str,
675 is_first_line: bool,
676 ) -> Line<'static> {
677 let chars: Vec<char> = line.chars().collect();
678 let mut spans = Vec::new();
679 let prompt_len = if is_first_line {
680 prompt.chars().count()
681 } else {
682 0
683 };
684
685 if is_first_line && prompt_len <= chars.len() {
686 let prompt_part: String = chars[..prompt_len].iter().collect();
687 spans.push(Span::styled(
688 prompt_part,
689 Style::default().fg(Color::Magenta),
690 ));
691
692 let text_part: String = chars[prompt_len..].iter().collect();
693 spans.push(Span::styled(text_part, Style::default()));
694 } else {
695 spans.push(Span::styled(line.to_string(), Style::default()));
696 }
697
698 Line::from(spans)
699 }
700
701 fn add_command_cursor(&self, line: &mut Line<'static>, cursor_col: usize) {
703 let mut new_spans = Vec::new();
704 let mut current_pos = 0;
705
706 for span in &line.spans {
707 let span_len = span.content.chars().count();
708 let span_end = current_pos + span_len;
709
710 if cursor_col >= current_pos && cursor_col < span_end {
711 let chars: Vec<char> = span.content.chars().collect();
712 let cursor_pos_in_span = cursor_col - current_pos;
713
714 if cursor_pos_in_span > 0 {
715 let before: String = chars[..cursor_pos_in_span].iter().collect();
716 new_spans.push(Span::styled(before, span.style));
717 }
718
719 if cursor_pos_in_span < chars.len() {
720 let cursor_char = chars[cursor_pos_in_span];
721 new_spans.push(Span::styled(
722 cursor_char.to_string(),
723 UIThemes::cursor_style(),
724 ));
725
726 if cursor_pos_in_span + 1 < chars.len() {
727 let after: String = chars[cursor_pos_in_span + 1..].iter().collect();
728 new_spans.push(Span::styled(after, span.style));
729 }
730 } else {
731 new_spans.push(Span::styled(" ".to_string(), UIThemes::cursor_style()));
732 }
733 } else {
734 new_spans.push(span.clone());
735 }
736
737 current_pos = span_end;
738 }
739
740 if cursor_col >= current_pos {
741 new_spans.push(Span::styled(" ".to_string(), UIThemes::cursor_style()));
742 }
743
744 line.spans = new_spans;
745 }
746
747 fn create_input_line(
749 &self,
750 prompt: &str,
751 input_text: &str,
752 cursor_pos: usize,
753 state: &CommandPanelState,
754 ) -> Line<'static> {
755 let chars: Vec<char> = input_text.chars().collect();
756 let mut spans = vec![Span::styled(
757 prompt.to_string(),
758 Style::default().fg(Color::Magenta),
759 )];
760
761 let show_cursor = matches!(state.mode, InteractionMode::Input);
762
763 if chars.is_empty() {
764 if let Some(suggestion_text) = state.get_suggestion_text() {
765 let suggestion_chars: Vec<char> = suggestion_text.chars().collect();
766 if !suggestion_chars.is_empty() {
767 spans.push(Span::styled(
768 suggestion_chars[0].to_string(),
769 if show_cursor {
770 UIThemes::cursor_style()
771 } else {
772 Style::default().fg(Color::DarkGray)
773 },
774 ));
775 if suggestion_chars.len() > 1 {
776 let remaining: String = suggestion_chars[1..].iter().collect();
777 spans.push(Span::styled(
778 remaining,
779 Style::default().fg(Color::DarkGray),
780 ));
781 }
782 }
783 } else {
784 spans.push(Span::styled(
785 " ".to_string(),
786 if show_cursor {
787 UIThemes::cursor_style()
788 } else {
789 Style::default()
790 },
791 ));
792 }
793 } else if cursor_pos >= chars.len() {
794 if let Some(suggestion_text) = state.get_suggestion_text() {
796 let full_text = format!("{input_text}{suggestion_text}");
798 let full_chars: Vec<char> = full_text.chars().collect();
799
800 if !input_text.is_empty() {
802 spans.push(Span::styled(input_text.to_string(), Style::default()));
803 }
804
805 if cursor_pos < full_chars.len() {
807 let cursor_char = full_chars[cursor_pos];
808 spans.push(Span::styled(
809 cursor_char.to_string(),
810 if show_cursor {
811 UIThemes::cursor_style()
812 } else {
813 Style::default().fg(Color::DarkGray)
814 },
815 ));
816
817 if cursor_pos + 1 < full_chars.len() {
819 let remaining: String = full_chars[cursor_pos + 1..].iter().collect();
820 spans.push(Span::styled(
821 remaining,
822 Style::default().fg(Color::DarkGray),
823 ));
824 }
825 } else {
826 spans.push(Span::styled(
828 " ".to_string(),
829 if show_cursor {
830 UIThemes::cursor_style()
831 } else {
832 Style::default()
833 },
834 ));
835 }
836 } else {
837 spans.push(Span::styled(input_text.to_string(), Style::default()));
839 spans.push(Span::styled(
840 " ".to_string(),
841 if show_cursor {
842 UIThemes::cursor_style()
843 } else {
844 Style::default()
845 },
846 ));
847 }
848 } else {
849 let before_cursor: String = chars[..cursor_pos].iter().collect();
851 let at_cursor = chars[cursor_pos];
852 let after_cursor: String = chars[cursor_pos + 1..].iter().collect();
853
854 if !before_cursor.is_empty() {
856 spans.push(Span::styled(before_cursor, Style::default()));
857 }
858
859 spans.push(Span::styled(
861 at_cursor.to_string(),
862 if show_cursor {
863 UIThemes::cursor_style()
864 } else {
865 Style::default()
866 },
867 ));
868
869 if !after_cursor.is_empty() {
871 spans.push(Span::styled(after_cursor, Style::default()));
872 }
873
874 if cursor_pos + 1 >= chars.len() {
876 if let Some(suggestion_text) = state.get_suggestion_text() {
877 spans.push(Span::styled(
878 suggestion_text.to_string(),
879 Style::default().fg(Color::DarkGray),
880 ));
881 }
882 }
883 }
884
885 Line::from(spans)
886 }
887
888 fn create_highlighted_script_line(&self, line_number: &str, content: &str) -> Line<'static> {
890 let mut spans = vec![Span::styled(
891 line_number.to_string(),
892 Style::default().fg(Color::DarkGray),
893 )];
894
895 let highlighted_spans = syntax_highlighter::highlight_line(content);
897 spans.extend(highlighted_spans);
898
899 Line::from(spans)
900 }
901
902 fn create_script_line_with_cursor(
904 &self,
905 line_number: &str,
906 content: &str,
907 cursor_pos: usize,
908 ) -> Line<'static> {
909 let chars: Vec<char> = content.chars().collect();
910 let mut spans = vec![Span::styled(
911 line_number.to_string(),
912 Style::default().fg(Color::DarkGray),
913 )];
914
915 if chars.is_empty() {
916 spans.push(Span::styled(" ".to_string(), UIThemes::cursor_style()));
917 } else if cursor_pos >= chars.len() {
918 let highlighted_spans = syntax_highlighter::highlight_line(content);
920 spans.extend(highlighted_spans);
921 spans.push(Span::styled(" ".to_string(), UIThemes::cursor_style()));
922 } else {
923 let before_cursor: String = chars[..cursor_pos].iter().collect();
925 let at_cursor = chars[cursor_pos];
926 let after_cursor: String = chars[cursor_pos + 1..].iter().collect();
927
928 if !before_cursor.is_empty() {
930 let before_spans = syntax_highlighter::highlight_line(&before_cursor);
931 spans.extend(before_spans);
932 }
933
934 spans.push(Span::styled(
936 at_cursor.to_string(),
937 UIThemes::cursor_style(),
938 ));
939
940 if !after_cursor.is_empty() {
942 let after_spans = syntax_highlighter::highlight_line(&after_cursor);
943 spans.extend(after_spans);
944 }
945 }
946
947 Line::from(spans)
948 }
949
950 fn create_command_line(&self, content: &str) -> Line<'static> {
952 if content.starts_with("(ghostscope) ") {
953 let prompt_part = "(ghostscope) ";
954 let command_part = &content[prompt_part.len()..];
955
956 Line::from(vec![
957 Span::styled(
958 prompt_part.to_string(),
959 Style::default().fg(Color::DarkGray),
960 ),
961 Span::styled(command_part.to_string(), Style::default().fg(Color::Gray)),
962 ])
963 } else {
964 Line::from(Span::styled(
965 content.to_string(),
966 Style::default().fg(Color::Gray),
967 ))
968 }
969 }
970
971 fn create_response_line(
973 &self,
974 content: &str,
975 response_type: Option<ResponseType>,
976 ) -> Line<'static> {
977 if content.contains("\x1b[") {
979 Line::from(self.parse_ansi_colors(content))
981 } else {
982 let style = match response_type {
984 Some(ResponseType::Success) => Style::default().fg(Color::Green),
985 Some(ResponseType::Error) => Style::default().fg(Color::Red),
986 Some(ResponseType::Warning) => Style::default().fg(Color::Yellow),
987 Some(ResponseType::Info) => Style::default().fg(Color::Cyan),
988 Some(ResponseType::Progress) => Style::default().fg(Color::Blue),
989 Some(ResponseType::ScriptDisplay) => Style::default().fg(Color::Magenta),
990 None => Style::default(), };
992 Line::from(Span::styled(content.to_string(), style))
993 }
994 }
995
996 fn create_fallback_welcome_line(&self, content: &str) -> Line<'static> {
998 if content.contains("GhostScope") {
999 Line::from(Span::styled(
1000 content.to_string(),
1001 Style::default()
1002 .fg(Color::Green)
1003 .add_modifier(Modifier::BOLD),
1004 ))
1005 } else if content.starts_with("•") || content.starts_with("Loading completed in") {
1006 Line::from(Span::styled(
1007 content.to_string(),
1008 Style::default().fg(Color::Cyan),
1009 ))
1010 } else if content.starts_with("Attached to process") {
1011 Line::from(Span::styled(
1012 content.to_string(),
1013 Style::default().fg(Color::White),
1014 ))
1015 } else if content.trim().is_empty() {
1016 Line::from("")
1017 } else {
1018 Line::from(Span::styled(
1019 content.to_string(),
1020 Style::default().fg(Color::White),
1021 ))
1022 }
1023 }
1024
1025 fn add_text_with_cursor(&self, spans: &mut Vec<Span<'static>>, text: &str, cursor_pos: usize) {
1027 let chars: Vec<char> = text.chars().collect();
1028
1029 if chars.is_empty() {
1030 spans.push(Span::styled(" ".to_string(), UIThemes::cursor_style()));
1031 } else if cursor_pos == 0 {
1032 let first_char = chars[0];
1033 spans.push(Span::styled(
1034 first_char.to_string(),
1035 UIThemes::cursor_style(),
1036 ));
1037 if chars.len() > 1 {
1038 let remaining: String = chars[1..].iter().collect();
1039 spans.push(Span::styled(remaining, Style::default()));
1040 }
1041 } else if cursor_pos >= chars.len() {
1042 spans.push(Span::styled(text.to_string(), Style::default()));
1043 spans.push(Span::styled(" ".to_string(), UIThemes::cursor_style()));
1044 } else {
1045 let before_cursor: String = chars[..cursor_pos].iter().collect();
1046 let at_cursor = chars[cursor_pos];
1047 let after_cursor: String = chars[cursor_pos + 1..].iter().collect();
1048
1049 if !before_cursor.is_empty() {
1050 spans.push(Span::styled(before_cursor, Style::default()));
1051 }
1052
1053 spans.push(Span::styled(
1054 at_cursor.to_string(),
1055 UIThemes::cursor_style(),
1056 ));
1057
1058 if !after_cursor.is_empty() {
1059 spans.push(Span::styled(after_cursor, Style::default()));
1060 }
1061 }
1062 }
1063
1064 fn add_text_with_cursor_and_suggestion(
1066 &self,
1067 spans: &mut Vec<Span<'static>>,
1068 text: &str,
1069 cursor_pos: usize,
1070 state: &CommandPanelState,
1071 ) {
1072 let chars: Vec<char> = text.chars().collect();
1073 let show_cursor = matches!(state.mode, InteractionMode::Input);
1074
1075 if chars.is_empty() {
1076 if let Some(suggestion_text) = state.get_suggestion_text() {
1077 let suggestion_chars: Vec<char> = suggestion_text.chars().collect();
1078 if !suggestion_chars.is_empty() {
1079 spans.push(Span::styled(
1080 suggestion_chars[0].to_string(),
1081 if show_cursor {
1082 UIThemes::cursor_style()
1083 } else {
1084 Style::default().fg(Color::DarkGray)
1085 },
1086 ));
1087
1088 if suggestion_chars.len() > 1 {
1089 let remaining: String = suggestion_chars[1..].iter().collect();
1090 spans.push(Span::styled(
1091 remaining,
1092 Style::default().fg(Color::DarkGray),
1093 ));
1094 }
1095 } else {
1096 spans.push(Span::styled(
1097 " ".to_string(),
1098 if show_cursor {
1099 UIThemes::cursor_style()
1100 } else {
1101 Style::default()
1102 },
1103 ));
1104 }
1105 } else {
1106 spans.push(Span::styled(
1107 " ".to_string(),
1108 if show_cursor {
1109 UIThemes::cursor_style()
1110 } else {
1111 Style::default()
1112 },
1113 ));
1114 }
1115 } else if cursor_pos >= chars.len() {
1116 if let Some(suggestion_text) = state.get_suggestion_text() {
1118 let full_text = format!("{text}{suggestion_text}");
1120 let full_chars: Vec<char> = full_text.chars().collect();
1121
1122 if !text.is_empty() {
1124 spans.push(Span::styled(text.to_string(), Style::default()));
1125 }
1126
1127 if cursor_pos < full_chars.len() {
1129 let cursor_char = full_chars[cursor_pos];
1130 spans.push(Span::styled(
1131 cursor_char.to_string(),
1132 if show_cursor {
1133 UIThemes::cursor_style()
1134 } else {
1135 Style::default().fg(Color::DarkGray)
1136 },
1137 ));
1138
1139 if cursor_pos + 1 < full_chars.len() {
1141 let remaining: String = full_chars[cursor_pos + 1..].iter().collect();
1142 spans.push(Span::styled(
1143 remaining,
1144 Style::default().fg(Color::DarkGray),
1145 ));
1146 }
1147 } else {
1148 spans.push(Span::styled(
1150 " ".to_string(),
1151 if show_cursor {
1152 UIThemes::cursor_style()
1153 } else {
1154 Style::default()
1155 },
1156 ));
1157 }
1158 } else {
1159 spans.push(Span::styled(text.to_string(), Style::default()));
1161 spans.push(Span::styled(
1162 " ".to_string(),
1163 if show_cursor {
1164 UIThemes::cursor_style()
1165 } else {
1166 Style::default()
1167 },
1168 ));
1169 }
1170 } else {
1171 let before_cursor: String = chars[..cursor_pos].iter().collect();
1173 let at_cursor = chars[cursor_pos];
1174 let after_cursor: String = chars[cursor_pos + 1..].iter().collect();
1175
1176 if !before_cursor.is_empty() {
1177 spans.push(Span::styled(before_cursor, Style::default()));
1178 }
1179
1180 spans.push(Span::styled(
1181 at_cursor.to_string(),
1182 if show_cursor {
1183 UIThemes::cursor_style()
1184 } else {
1185 Style::default()
1186 },
1187 ));
1188
1189 if !after_cursor.is_empty() {
1190 spans.push(Span::styled(after_cursor, Style::default()));
1191 }
1192
1193 if cursor_pos + 1 >= chars.len() {
1195 if let Some(suggestion_text) = state.get_suggestion_text() {
1196 spans.push(Span::styled(
1197 suggestion_text.to_string(),
1198 Style::default().fg(Color::DarkGray),
1199 ));
1200 }
1201 }
1202 }
1203 }
1204
1205 fn wrap_text(&self, text: &str, width: u16) -> Vec<String> {
1207 if width <= 2 {
1208 return vec![text.to_string()];
1209 }
1210
1211 let max_width = width as usize;
1212 let mut lines = Vec::new();
1213
1214 for line in text.lines() {
1215 let line_width = self.visible_width(line);
1217
1218 if line_width <= max_width {
1219 lines.push(line.to_string());
1220 } else {
1221 let mut current_line = String::new();
1222 let mut current_width = 0;
1223 let mut chars = line.chars().peekable();
1224 let mut in_ansi_sequence = false;
1225 let mut active_color_code = String::new(); while let Some(ch) = chars.next() {
1228 if ch == '\x1b' && chars.peek() == Some(&'[') {
1230 in_ansi_sequence = true;
1231 current_line.push(ch);
1232 active_color_code.clear();
1233 active_color_code.push(ch);
1234 continue;
1235 }
1236
1237 if in_ansi_sequence {
1239 current_line.push(ch);
1240 active_color_code.push(ch);
1241 if ch == 'm' {
1242 in_ansi_sequence = false;
1243 if active_color_code == "\x1b[0m" {
1245 active_color_code.clear();
1246 }
1247 }
1248 continue;
1249 }
1250
1251 let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
1253
1254 if current_width + char_width > max_width && !current_line.is_empty() {
1255 if !active_color_code.is_empty() && !current_line.ends_with("\x1b[0m") {
1257 current_line.push_str("\x1b[0m");
1258 }
1259 lines.push(current_line);
1260
1261 current_line = String::new();
1263 if !active_color_code.is_empty() && active_color_code != "\x1b[0m" {
1264 current_line.push_str(&active_color_code);
1265 }
1266 current_line.push(ch);
1267 current_width = char_width;
1268 } else {
1269 current_line.push(ch);
1270 current_width += char_width;
1271 }
1272 }
1273
1274 if !current_line.is_empty() {
1275 lines.push(current_line);
1276 }
1277 }
1278 }
1279
1280 if lines.is_empty() {
1281 lines.push(String::new());
1282 }
1283
1284 lines
1285 }
1286
1287 fn visible_width(&self, text: &str) -> usize {
1289 let mut width = 0;
1290 let mut chars = text.chars().peekable();
1291 let mut in_ansi_sequence = false;
1292
1293 while let Some(ch) = chars.next() {
1294 if ch == '\x1b' && chars.peek() == Some(&'[') {
1295 in_ansi_sequence = true;
1296 continue;
1297 }
1298
1299 if in_ansi_sequence {
1300 if ch == 'm' {
1301 in_ansi_sequence = false;
1302 }
1303 continue;
1304 }
1305
1306 width += UnicodeWidthChar::width(ch).unwrap_or(0);
1307 }
1308
1309 width
1310 }
1311
1312 fn wrap_styled_line(&self, styled_line: &Line<'static>, width: usize) -> Vec<Line<'static>> {
1314 let plain_text: String = styled_line
1316 .spans
1317 .iter()
1318 .map(|span| span.content.as_ref())
1319 .collect();
1320
1321 let wrapped_texts = self.wrap_text(&plain_text, width as u16);
1323
1324 if wrapped_texts.len() <= 1 {
1325 return vec![styled_line.clone()];
1326 }
1327
1328 let mut result_lines = Vec::new();
1330 let mut span_index = 0;
1331 let mut span_char_offset = 0;
1332
1333 for wrapped_text in wrapped_texts {
1334 let mut current_line_spans = Vec::new();
1335 let mut chars_needed = wrapped_text.chars().count();
1336
1337 while chars_needed > 0 && span_index < styled_line.spans.len() {
1338 let span = &styled_line.spans[span_index];
1339 let span_text = span.content.as_ref();
1340 let span_chars: Vec<char> = span_text.chars().collect();
1341
1342 let available_chars = span_chars.len() - span_char_offset;
1343 let chars_to_take = chars_needed.min(available_chars);
1344
1345 let taken_text: String = span_chars
1346 [span_char_offset..span_char_offset + chars_to_take]
1347 .iter()
1348 .collect();
1349
1350 if !taken_text.is_empty() {
1351 current_line_spans.push(Span::styled(taken_text, span.style));
1352 }
1353
1354 span_char_offset += chars_to_take;
1355 chars_needed -= chars_to_take;
1356
1357 if span_char_offset >= span_chars.len() {
1358 span_index += 1;
1359 span_char_offset = 0;
1360 }
1361 }
1362
1363 if !current_line_spans.is_empty() {
1364 result_lines.push(Line::from(current_line_spans));
1365 }
1366 }
1367
1368 result_lines
1369 }
1370
1371 pub fn scroll_up(&mut self) {
1373 if self.scroll_offset > 0 {
1374 self.scroll_offset -= 1;
1375 }
1376 }
1377
1378 pub fn scroll_down(&mut self) {
1379 self.scroll_offset += 1;
1380 }
1381
1382 fn parse_ansi_colors(&self, text: &str) -> Vec<Span<'static>> {
1384 let mut spans = Vec::new();
1385 let mut current_text = String::new();
1386 let mut current_style = Style::default();
1387 let mut chars = text.chars().peekable();
1388
1389 while let Some(ch) = chars.next() {
1390 if ch == '\x1b' && chars.peek() == Some(&'[') {
1391 if !current_text.is_empty() {
1393 spans.push(Span::styled(current_text.clone(), current_style));
1394 current_text.clear();
1395 }
1396
1397 chars.next();
1399
1400 let mut code = String::new();
1402 while let Some(&c) = chars.peek() {
1403 if c == 'm' {
1404 chars.next(); break;
1406 }
1407 code.push(c);
1408 chars.next();
1409 }
1410
1411 current_style = match code.as_str() {
1413 "0" => Style::default(), "31" => Style::default().fg(Color::Red), "32" => Style::default().fg(Color::Green), "33" => Style::default().fg(Color::Yellow), "34" => Style::default().fg(Color::Blue), "35" => Style::default().fg(Color::Magenta), "36" => Style::default().fg(Color::Cyan), _ => current_style, };
1422 } else {
1423 current_text.push(ch);
1424 }
1425 }
1426
1427 if !current_text.is_empty() {
1429 spans.push(Span::styled(current_text, current_style));
1430 }
1431
1432 if spans.is_empty() {
1433 spans.push(Span::raw(text.to_string()));
1434 }
1435
1436 spans
1437 }
1438}
1439
1440impl Default for OptimizedRenderer {
1441 fn default() -> Self {
1442 Self::new()
1443 }
1444}