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