1use crate::core::prelude::*;
2use crate::ui::color::AppColor;
3use crate::ui::cursor::{CursorKind, UiCursor};
4use crate::ui::viewport::{ScrollDirection, Viewport, ViewportEvent};
5use ratatui::{
6 style::Style,
7 text::{Line, Span},
8 widgets::{Block, Borders, Paragraph, Wrap},
9};
10use strip_ansi_escapes::strip;
11use unicode_segmentation::UnicodeSegmentation;
12
13#[derive(Debug)]
14pub struct Message {
15 pub content: String,
16 pub current_length: usize,
17 pub timestamp: Instant,
18 pub line_count: usize,
19 pub typewriter_cursor: Option<UiCursor>,
20}
21
22type RenderData<'a> = (
23 Vec<(String, usize, bool, bool, bool)>,
24 Config,
25 crate::ui::viewport::LayoutArea,
26 &'a UiCursor,
27);
28
29impl Message {
30 pub fn new(content: String, typewriter_delay: Duration) -> Self {
31 let line_count = 1;
32
33 let initial_length = if typewriter_delay.as_millis() == 0 {
34 content.graphemes(true).count()
35 } else {
36 0
37 };
38
39 let typewriter_cursor = if typewriter_delay.as_millis() > 0 {
40 Some(UiCursor::for_typewriter())
41 } else {
42 None
43 };
44
45 Self {
46 content,
47 current_length: initial_length,
48 timestamp: Instant::now(),
49 line_count,
50 typewriter_cursor,
51 }
52 }
53
54 pub fn calculate_wrapped_line_count(&mut self, viewport: &Viewport) {
55 let clean_content = clean_message_for_display(&self.content);
56
57 if clean_content.is_empty() {
58 self.line_count = 1;
59 return;
60 }
61
62 let output_area = viewport.output_area();
63 let available_width = (output_area.width as usize).saturating_sub(2);
64 let effective_width = available_width.max(10);
65
66 let mut total_lines = 0;
67
68 for line in clean_content.lines() {
69 if line.is_empty() {
70 total_lines += 1;
71 } else {
72 let line_chars = line.graphemes(true).count();
73 if line_chars == 0 {
74 total_lines += 1;
75 } else {
76 let wrapped_lines = ((line_chars - 1) / effective_width) + 1;
77 total_lines += wrapped_lines;
78 }
79 }
80 }
81
82 self.line_count = total_lines.max(1);
83 }
84
85 pub fn is_typing(&self) -> bool {
86 if self.typewriter_cursor.is_some() {
87 let total_length = self.content.graphemes(true).count();
88 self.current_length < total_length
89 } else {
90 false
91 }
92 }
93
94 pub fn is_cursor_visible(&self) -> bool {
95 if let Some(ref cursor) = self.typewriter_cursor {
96 cursor.is_visible()
97 } else {
98 false
99 }
100 }
101}
102
103static EMPTY_STRING: &str = "";
104
105pub struct MessageDisplay {
106 messages: Vec<Message>,
107 config: Config,
108 viewport: Viewport,
109 persistent_cursor: UiCursor,
110}
111
112impl MessageDisplay {
113 pub fn new(config: &Config, terminal_width: u16, terminal_height: u16) -> Self {
114 let viewport = Viewport::new(terminal_width, terminal_height);
115
116 let persistent_cursor = UiCursor::from_config(config, CursorKind::Output);
118
119 Self {
120 messages: Vec::with_capacity(config.max_messages),
121 config: config.clone(),
122 viewport,
123 persistent_cursor, }
125 }
126
127 pub fn update_config(&mut self, new_config: &Config) {
128 let old_cursor_config = self.config.theme.output_cursor.clone();
129 let old_cursor_color = self.config.theme.output_cursor_color;
130 let new_cursor_config = new_config.theme.output_cursor.clone();
131 let new_cursor_color = new_config.theme.output_cursor_color;
132 let old_theme = self.config.current_theme_name.clone();
133 let new_theme = new_config.current_theme_name.clone();
134
135 log::info!(
136 "📊 MessageDisplay CONFIG UPDATE START: '{}' → '{}' | output_cursor: '{}' ({}) → '{}' ({})",
137 old_theme,
138 new_theme,
139 old_cursor_config,
140 old_cursor_color.to_name(),
141 new_cursor_config,
142 new_cursor_color.to_name()
143 );
144
145 self.config = new_config.clone();
147
148 log::info!("🔄 RECREATING output cursor with new config...");
150 let old_cursor_debug = self.persistent_cursor.debug_info();
151 self.persistent_cursor = UiCursor::from_config(new_config, CursorKind::Output);
152 let new_cursor_debug = self.persistent_cursor.debug_info();
153
154 log::info!(
155 "🔄 OUTPUT-CURSOR TRANSITION:\n OLD: {}\n NEW: {}",
156 old_cursor_debug,
157 new_cursor_debug
158 );
159
160 if self.messages.len() > self.config.max_messages {
162 let excess = self.messages.len() - self.config.max_messages;
163 self.messages.drain(0..excess);
164 self.recalculate_content_height();
165 }
166
167 let final_symbol = self.persistent_cursor.get_symbol();
169 let final_color = self.persistent_cursor.color.to_name();
170 log::info!(
171 "✅ MessageDisplay CONFIG UPDATE COMPLETE: output_cursor_symbol='{}' ({}), expected='{}' ({})",
172 final_symbol,
173 final_color,
174 new_config.theme.output_cursor,
175 new_config.theme.output_cursor_color.to_name()
176 );
177 }
178
179 pub fn handle_viewport_event(&mut self, event: ViewportEvent) -> bool {
180 let changed = self.viewport.handle_event(event);
181 if changed {
182 self.recalculate_all_line_counts();
183 log::debug!("📐 Viewport updated: {}", self.viewport.debug_info());
184 }
185 changed
186 }
187
188 pub fn handle_resize(&mut self, width: u16, height: u16) -> bool {
189 let changed = self.handle_viewport_event(ViewportEvent::TerminalResized { width, height });
190
191 if changed {
192 self.viewport.force_auto_scroll();
193 }
194
195 changed
196 }
197
198 pub fn clear_messages(&mut self) {
199 self.messages.clear();
200 self.recalculate_content_height();
201 self.viewport.force_auto_scroll();
202 self.persistent_cursor.show_cursor();
203
204 log::debug!("🗑️ All messages cleared, persistent cursor remains active");
205 }
206
207 pub fn add_message(&mut self, content: String) {
208 if content.starts_with("[DEBUG]") || content.starts_with("[TRACE]") {
209 log::info!("STDERR: {}", content);
210 return;
211 }
212
213 if self.messages.len() >= self.config.max_messages {
214 self.messages.remove(0);
215 }
216
217 let mut message = Message::new(content, self.config.typewriter_delay);
218 message.calculate_wrapped_line_count(&self.viewport);
219
220 self.messages.push(message);
221 self.recalculate_content_height_silent();
222 self.scroll_to_bottom_direct_silent();
223 }
224
225 pub fn update_typewriter(&mut self) {
226 self.persistent_cursor.update_blink();
227
228 if self.config.typewriter_delay.as_millis() == 0 {
229 return;
230 }
231
232 if let Some(last_message) = self.messages.last_mut() {
233 let total_length = last_message.content.graphemes(true).count();
234
235 if let Some(ref mut cursor) = last_message.typewriter_cursor {
236 cursor.update_blink();
237 }
238
239 if last_message.current_length < total_length {
240 let elapsed = last_message.timestamp.elapsed();
241
242 if elapsed >= self.config.typewriter_delay {
243 let chars_to_add = if self.config.typewriter_delay.as_millis() <= 5 {
244 let ratio = elapsed.as_millis() as f64
245 / self.config.typewriter_delay.as_millis() as f64;
246 ratio.floor().max(1.0) as usize
247 } else {
248 1
249 };
250
251 let new_length = (last_message.current_length + chars_to_add).min(total_length);
252 last_message.current_length = new_length;
253 last_message.timestamp = Instant::now();
254
255 if new_length == total_length {
256 last_message.typewriter_cursor = None;
257 self.viewport.force_auto_scroll();
258 log::trace!("⌨️ Typewriter completed → message cursor removed, persistent cursor continues");
259 }
260 }
261 }
262 }
263 }
264
265 fn recalculate_content_height_silent(&mut self) {
266 let total_lines = self
267 .messages
268 .iter()
269 .map(|msg| msg.line_count)
270 .sum::<usize>();
271
272 self.viewport.update_content_height_silent(total_lines);
273 }
274
275 fn scroll_to_bottom_direct_silent(&mut self) {
276 self.viewport.enable_auto_scroll_silent();
277
278 let content_height = self.viewport.content_height();
279 let window_height = self.viewport.window_height();
280
281 if content_height > window_height {
282 let max_offset = content_height - window_height;
283 self.viewport.set_scroll_offset_direct_silent(max_offset);
284 } else {
285 self.viewport.set_scroll_offset_direct_silent(0);
286 }
287 }
288
289 fn recalculate_all_line_counts(&mut self) {
290 for message in &mut self.messages {
291 message.calculate_wrapped_line_count(&self.viewport);
292 }
293
294 self.recalculate_content_height();
295
296 log::debug!(
297 "🔄 Recalculated all line counts for output_width: {}, total messages: {}",
298 self.viewport.output_area().width,
299 self.messages.len()
300 );
301 }
302
303 pub fn handle_scroll(&mut self, direction: ScrollDirection, amount: usize) {
304 let scroll_amount = match direction {
305 ScrollDirection::Up | ScrollDirection::Down => {
306 if amount == 0 {
307 1
308 } else {
309 amount
310 }
311 }
312 ScrollDirection::PageUp | ScrollDirection::PageDown => 0,
313 _ => amount,
314 };
315
316 log::trace!("📜 Manual scroll: {:?} by {}", direction, scroll_amount);
317
318 self.handle_viewport_event(ViewportEvent::ScrollRequest {
319 direction,
320 amount: scroll_amount,
321 });
322 }
323
324 fn recalculate_content_height(&mut self) {
325 let individual_line_counts: Vec<usize> =
326 self.messages.iter().map(|msg| msg.line_count).collect();
327
328 let total_lines = individual_line_counts.iter().sum::<usize>();
329
330 log::debug!(
331 "📊 Recalculating content height: {} messages → {} total lines",
332 self.messages.len(),
333 total_lines
334 );
335
336 let old_content_height = self.viewport.content_height();
337 self.viewport.update_content_height(total_lines);
338
339 let new_content_height = self.viewport.content_height();
340
341 log::debug!(
342 "📊 Content height updated: {} → {} (window: {})",
343 old_content_height,
344 new_content_height,
345 self.viewport.window_height()
346 );
347 }
348
349 pub fn get_content_height(&self) -> usize {
350 self.viewport.content_height()
351 }
352
353 pub fn get_window_height(&self) -> usize {
354 self.viewport.window_height()
355 }
356
357 pub fn get_visible_messages(&self) -> Vec<(String, usize, bool, bool, bool)> {
358 let window_height = self.viewport.window_height();
359 let content_height = self.viewport.content_height();
360
361 if self.messages.is_empty() {
362 return vec![(
363 EMPTY_STRING.to_string(),
364 0,
365 false,
366 false,
367 self.persistent_cursor.is_visible(),
368 )];
369 }
370
371 if content_height <= window_height {
372 let mut result: Vec<(String, usize, bool, bool, bool)> = self
373 .messages
374 .iter()
375 .enumerate()
376 .map(|(index, msg)| {
377 let is_last = index == self.messages.len() - 1;
378 (
379 msg.content.clone(),
380 msg.current_length,
381 msg.is_typing(),
382 msg.is_cursor_visible(),
383 is_last && self.persistent_cursor.is_visible(),
384 )
385 })
386 .collect();
387
388 if let Some(last_msg) = self.messages.last() {
389 if !last_msg.is_typing() {
390 result.push((
391 EMPTY_STRING.to_string(),
392 0,
393 false,
394 false,
395 self.persistent_cursor.is_visible(),
396 ));
397 }
398 }
399
400 return result;
401 }
402
403 let mut visible = Vec::new();
404 let mut lines_used = 0;
405
406 for (index, message) in self.messages.iter().rev().enumerate() {
407 if lines_used + message.line_count <= window_height {
408 let is_last = index == 0;
409 visible.push((
410 message.content.clone(),
411 message.current_length,
412 message.is_typing(),
413 message.is_cursor_visible(),
414 is_last && self.persistent_cursor.is_visible(),
415 ));
416 lines_used += message.line_count;
417 } else {
418 break;
419 }
420 }
421
422 visible.reverse();
423
424 if let Some((_, _, is_typing, _, _)) = visible.last() {
425 if !is_typing && lines_used < window_height {
426 visible.push((
427 EMPTY_STRING.to_string(),
428 0,
429 false,
430 false,
431 self.persistent_cursor.is_visible(),
432 ));
433 }
434 }
435
436 visible
437 }
438
439 pub fn create_output_widget_for_rendering(&self) -> RenderData<'_> {
440 let messages = self.get_visible_messages();
441 (
442 messages,
443 self.config.clone(),
444 self.viewport.output_area(),
445 &self.persistent_cursor,
446 )
447 }
448
449 pub fn viewport(&self) -> &Viewport {
450 &self.viewport
451 }
452
453 pub fn viewport_mut(&mut self) -> &mut Viewport {
454 &mut self.viewport
455 }
456
457 pub fn debug_scroll_status(&self) -> String {
458 format!(
459 "Scroll: offset={}, content_height={}, window_height={}, auto_scroll={}, at_bottom={}",
460 self.viewport.scroll_offset(),
461 self.viewport.content_height(),
462 self.viewport.window_height(),
463 self.viewport.is_auto_scroll_enabled(),
464 self.viewport.scroll_offset()
465 >= self
466 .viewport
467 .content_height()
468 .saturating_sub(self.viewport.window_height())
469 )
470 }
471
472 pub fn log(&mut self, level: &str, message: &str) {
473 let log_message = format!("[{}] {}", level, message);
474 self.add_message(log_message);
475 }
476
477 pub fn get_messages_count(&self) -> usize {
478 self.messages.len()
479 }
480}
481
482fn clean_ansi_codes(message: &str) -> String {
484 String::from_utf8_lossy(&strip(message.as_bytes()).unwrap_or_default()).into_owned()
485}
486
487fn clean_message_for_display(message: &str) -> String {
488 clean_ansi_codes(message)
489 .replace("__CONFIRM_EXIT__", "")
490 .replace("__CLEAR__", "")
491 .trim()
492 .to_string()
493}
494
495fn parse_message_parts(message: &str) -> Vec<(String, bool)> {
496 let mut parts = Vec::new();
497 let mut chars = message.char_indices().peekable();
498 let mut start = 0;
499
500 while let Some((i, c)) = chars.peek().cloned() {
501 if c == '[' {
502 if start < i {
503 let text = &message[start..i];
504 if !text.trim().is_empty() {
505 parts.push((text.to_owned(), false));
506 }
507 }
508
509 if let Some(end_idx) = message[i..].find(']') {
510 let end = i + end_idx + 1;
511 parts.push((message[i..end].to_owned(), true));
512 start = end;
513 while let Some(&(ci, _)) = chars.peek() {
514 if ci < end {
515 chars.next();
516 } else {
517 break;
518 }
519 }
520 } else {
521 parts.push((message[i..].to_owned(), false));
522 break;
523 }
524 } else {
525 chars.next();
526 }
527 }
528
529 if start < message.len() {
530 let remaining = &message[start..];
531 if !remaining.trim().is_empty() {
532 parts.push((remaining.to_owned(), false));
533 }
534 }
535
536 if parts.is_empty() {
537 parts.push((message.to_owned(), false));
538 }
539
540 parts
541}
542
543fn get_marker_color(marker: &str) -> AppColor {
544 let display_category = marker
545 .trim_start_matches('[')
546 .trim_end_matches(']')
547 .trim_start_matches("cat:")
548 .to_lowercase();
549
550 if AppColor::from_any(&display_category).to_name() != "gray" {
551 return AppColor::from_any(&display_category);
552 }
553
554 let mapped_category = crate::i18n::get_color_category_for_display(&display_category);
555 AppColor::from_any(mapped_category)
556}
557
558pub fn create_output_widget<'a>(
559 messages: &'a [(String, usize, bool, bool, bool)],
560 layout_area: crate::ui::viewport::LayoutArea,
561 config: &'a Config,
562 cursor_state: &'a UiCursor,
563) -> Paragraph<'a> {
564 let max_lines = layout_area.height as usize;
565 let mut lines = Vec::new();
566
567 if max_lines == 0 || layout_area.width == 0 {
568 log::warn!(
569 "🚨 Invalid layout area: {}x{}",
570 layout_area.width,
571 layout_area.height
572 );
573 return Paragraph::new(vec![Line::from(vec![Span::raw("⚠️ INVALID LAYOUT")])]).block(
574 Block::default()
575 .borders(Borders::NONE)
576 .style(Style::default().bg(config.theme.output_bg.into())),
577 );
578 }
579
580 let safe_max_lines = max_lines.min(1000);
581
582 if messages.is_empty() {
583 let empty_lines = vec![Line::from(vec![Span::raw("")]); safe_max_lines];
584 return Paragraph::new(empty_lines)
585 .block(
586 Block::default()
587 .borders(Borders::NONE)
588 .style(Style::default().bg(config.theme.output_bg.into())),
589 )
590 .wrap(Wrap { trim: true });
591 }
592
593 for (
594 message_idx,
595 (message, current_length, is_typing, msg_cursor_visible, persistent_cursor_visible),
596 ) in messages.iter().enumerate()
597 {
598 let is_last_message = message_idx == messages.len() - 1;
599
600 if message.is_empty() {
601 if *persistent_cursor_visible {
602 lines.push(Line::from(vec![cursor_state.create_cursor_span(config)]));
604 } else {
605 lines.push(Line::from(vec![Span::raw("")]));
606 }
607 continue;
608 }
609
610 let clean_message = clean_message_for_display(message);
611 let message_lines: Vec<&str> = clean_message.lines().collect();
612
613 if message_lines.is_empty() {
614 lines.push(Line::from(vec![Span::raw("")]));
615 } else {
616 for (line_idx, line_content) in message_lines.iter().enumerate() {
617 if lines.len() >= safe_max_lines {
618 log::trace!("🛑 Reached safe line limit: {}", safe_max_lines);
619 break;
620 }
621
622 let is_last_line = line_idx == message_lines.len() - 1;
623
624 let visible_chars = if is_last_message && is_last_line {
625 let chars_before_this_line: usize = message_lines
626 .iter()
627 .take(line_idx)
628 .map(|l| l.graphemes(true).count() + 1)
629 .sum();
630
631 let available_for_this_line =
632 current_length.saturating_sub(chars_before_this_line);
633 available_for_this_line.min(line_content.graphemes(true).count())
634 } else {
635 line_content.graphemes(true).count()
636 };
637
638 let message_parts = parse_message_parts(line_content);
639 let mut spans = Vec::new();
640 let mut chars_used = 0;
641
642 for (part_text, is_marker) in message_parts {
643 let part_chars = part_text.graphemes(true).count();
644 let part_style = if is_marker {
645 Style::default().fg(get_marker_color(&part_text).into())
646 } else {
647 Style::default().fg(config.theme.output_text.into())
648 };
649
650 if chars_used >= visible_chars {
651 break;
652 }
653
654 let chars_needed = visible_chars - chars_used;
655
656 if chars_needed >= part_chars {
657 spans.push(Span::styled(part_text, part_style));
658 chars_used += part_chars;
659 } else {
660 let graphemes: Vec<&str> = part_text.graphemes(true).collect();
661 spans.push(Span::styled(
662 graphemes
663 .iter()
664 .take(chars_needed)
665 .copied()
666 .collect::<String>(),
667 part_style,
668 ));
669 break;
670 }
671 }
672
673 if is_last_message && is_last_line {
675 if *is_typing && *msg_cursor_visible {
676 spans.push(cursor_state.create_cursor_span(config));
678 } else if !*is_typing && *persistent_cursor_visible {
679 spans.push(cursor_state.create_cursor_span(config));
681 }
682 }
683
684 if spans.is_empty() {
685 spans.push(Span::raw(""));
686 }
687
688 lines.push(Line::from(spans));
689 }
690 }
691
692 if lines.len() >= safe_max_lines {
693 break;
694 }
695 }
696
697 while lines.len() < safe_max_lines {
698 lines.push(Line::from(vec![Span::raw("")]));
699 }
700
701 lines.truncate(safe_max_lines);
702
703 if lines.is_empty() {
704 log::error!("🚨 Empty lines vector created!");
705 lines.push(Line::from(vec![Span::raw("ERROR: Empty buffer")]));
706 }
707
708 log::trace!(
709 "✅ Output widget created: {} lines, output_cursor: {} ({})",
710 lines.len(),
711 cursor_state.get_symbol(),
712 cursor_state.color.to_name()
713 );
714
715 Paragraph::new(lines)
716 .block(
717 Block::default()
718 .borders(Borders::NONE)
719 .style(Style::default().bg(config.theme.output_bg.into())),
720 )
721 .wrap(Wrap { trim: true })
722}