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
22#[derive(Debug, Clone)]
23struct CachedLine {
24 content: String,
25 message_index: usize,
26 is_partial: bool,
27 visible_chars: usize,
28}
29
30type RenderData<'a> = (
31 Vec<(String, usize, bool, bool, bool)>,
32 Config,
33 crate::ui::viewport::LayoutArea,
34 &'a UiCursor,
35);
36
37impl Message {
38 pub fn new(content: String, typewriter_delay: Duration) -> Self {
39 let initial_length = if typewriter_delay.as_millis() == 0 {
40 content.graphemes(true).count() } else {
42 0
43 };
44
45 let typewriter_cursor = if typewriter_delay.as_millis() > 0 {
46 Some(UiCursor::for_typewriter()) } else {
48 None };
50
51 Self {
52 content,
53 current_length: initial_length,
54 timestamp: Instant::now(),
55 line_count: 1,
56 typewriter_cursor,
57 }
58 }
59
60 pub fn calculate_wrapped_line_count(&mut self, viewport: &Viewport) {
61 let clean_content = clean_message_for_display(&self.content);
62
63 if clean_content.is_empty() {
65 self.line_count = 1;
66 return;
67 }
68
69 let output_area = viewport.output_area();
70 let effective_width = (output_area.width as usize).saturating_sub(2).max(10);
71
72 let mut total_lines = 0;
74
75 let raw_lines: Vec<&str> = clean_content.lines().collect();
77
78 let lines_to_process = if clean_content.ends_with('\n') {
80 let mut lines = raw_lines;
81 lines.push("");
82 lines
83 } else if raw_lines.is_empty() {
84 vec![""]
85 } else {
86 raw_lines
87 };
88
89 for line in lines_to_process {
91 if line.is_empty() {
92 total_lines += 1; } else {
94 let line_chars = line.graphemes(true).count();
95 total_lines += ((line_chars.saturating_sub(1)) / effective_width) + 1;
97 }
98 }
99
100 self.line_count = total_lines.max(1);
101
102 log::trace!(
103 "📊 Message line count: {} lines (from {} chars, width {})",
104 self.line_count,
105 clean_content.len(),
106 effective_width
107 );
108 }
109
110 pub fn is_typing(&self) -> bool {
111 if self.typewriter_cursor.is_some() {
112 self.current_length < self.content.graphemes(true).count()
113 } else {
114 false
115 }
116 }
117
118 pub fn is_cursor_visible(&self) -> bool {
119 if let Some(ref cursor) = self.typewriter_cursor {
120 cursor.is_visible()
121 } else {
122 false
123 }
124 }
125}
126
127pub struct MessageDisplay {
128 messages: Vec<Message>,
129 line_cache: Vec<CachedLine>,
130 cache_dirty: bool,
131 config: Config,
132 viewport: Viewport,
133 persistent_cursor: UiCursor,
134 debug_enabled: bool,
135 debug_cycles: usize,
136}
137
138impl MessageDisplay {
139 pub fn new(config: &Config, terminal_width: u16, terminal_height: u16) -> Self {
140 let viewport = Viewport::new(terminal_width, terminal_height);
141 let persistent_cursor = UiCursor::from_config(config, CursorKind::Output);
142
143 Self::log_startup();
144
145 Self {
146 messages: Vec::with_capacity(config.max_messages),
147 line_cache: Vec::new(),
148 cache_dirty: true,
149 config: config.clone(),
150 viewport,
151 persistent_cursor,
152 debug_enabled: false,
153 debug_cycles: 0,
154 }
155 }
156
157 fn debug_log(&mut self, message: &str) {
158 if self.debug_enabled && self.debug_cycles > 0 {
159 log::info!("🔍 {}", message);
160 self.debug_cycles -= 1;
161 if self.debug_cycles == 0 {
162 self.debug_enabled = false;
163 log::info!("🔇 Debug disabled");
164 }
165 }
166 }
167
168 fn rebuild_line_cache(&mut self) {
170 self.line_cache.clear();
171
172 let output_area = self.viewport.output_area();
173 let effective_width = (output_area.width as usize).saturating_sub(2).max(10);
174
175 for (msg_idx, message) in self.messages.iter().enumerate() {
176 let original_content = &message.content;
177
178 let visible_content = if message.is_typing() {
180 let graphemes: Vec<&str> = original_content.graphemes(true).collect();
181 graphemes
182 .iter()
183 .take(message.current_length)
184 .copied()
185 .collect::<String>()
186 } else {
187 original_content.clone()
188 };
189
190 let clean_content = clean_message_for_display(&visible_content);
192
193 let raw_lines = if clean_content.is_empty() {
195 vec![String::new()]
196 } else {
197 let mut lines: Vec<String> = clean_content.lines().map(|s| s.to_string()).collect();
198
199 if clean_content.ends_with('\n') {
201 lines.push(String::new());
202 }
203
204 if lines.is_empty() {
205 lines.push(String::new());
206 }
207
208 lines
209 };
210
211 for (line_idx, raw_line) in raw_lines.iter().enumerate() {
213 if raw_line.is_empty() {
214 self.line_cache.push(CachedLine {
216 content: String::new(),
217 message_index: msg_idx,
218 is_partial: false,
219 visible_chars: 0,
220 });
221 } else {
222 let graphemes: Vec<&str> = raw_line.graphemes(true).collect();
224 let mut start = 0;
225
226 while start < graphemes.len() {
227 let end = (start + effective_width).min(graphemes.len());
228 let wrapped_line = graphemes[start..end].join("");
229
230 let is_last_chunk = end == graphemes.len();
231 let is_last_line = line_idx == raw_lines.len() - 1;
232
233 self.line_cache.push(CachedLine {
234 content: wrapped_line.clone(),
235 message_index: msg_idx,
236 is_partial: message.is_typing() && is_last_line && is_last_chunk,
237 visible_chars: wrapped_line.graphemes(true).count(),
238 });
239
240 start = end;
241 }
242 }
243 }
244 }
245
246 if let Some(last_msg) = self.messages.last() {
248 if !last_msg.is_typing() {
249 self.line_cache.push(CachedLine {
250 content: String::new(),
251 message_index: self.messages.len(),
252 is_partial: false,
253 visible_chars: 0,
254 });
255 }
256 }
257
258 self.cache_dirty = false;
259
260 let new_height = self.line_cache.len();
262 self.viewport.update_content_height_silent(new_height);
263
264 log::info!(
265 "🔄 Cache rebuilt: {} lines from {} messages (viewport: {}x{})",
266 self.line_cache.len(),
267 self.messages.len(),
268 self.viewport.window_height(),
269 effective_width
270 );
271 }
272
273 pub fn get_visible_messages(&mut self) -> Vec<(String, usize, bool, bool, bool)> {
275 if self.cache_dirty {
276 self.rebuild_line_cache();
277 }
278
279 let window_height = self.viewport.window_height();
280 let scroll_offset = self.viewport.scroll_offset();
281
282 let available_lines = self.line_cache.len().saturating_sub(scroll_offset);
284 let lines_to_show = available_lines.min(window_height);
285
286 let visible_start = scroll_offset;
287 let visible_end = scroll_offset + lines_to_show;
288
289 self.debug_log(&format!(
290 "Viewport: cache={}, offset={}, showing={}, range={}..{}",
291 self.line_cache.len(),
292 scroll_offset,
293 lines_to_show,
294 visible_start,
295 visible_end
296 ));
297
298 let mut result = Vec::new();
299
300 if self.line_cache.is_empty() {
301 result.push((
302 String::new(),
303 0,
304 false,
305 false,
306 self.persistent_cursor.is_visible(),
307 ));
308 return result;
309 }
310
311 for line_idx in visible_start..visible_end {
313 if let Some(cached_line) = self.line_cache.get(line_idx) {
314 let msg_idx = cached_line.message_index;
315 let is_last_line = line_idx == self.line_cache.len() - 1;
316
317 let (is_typing, cursor_visible) = if msg_idx < self.messages.len() {
318 if let Some(msg) = self.messages.get(msg_idx) {
319 (
320 cached_line.is_partial && msg.is_typing(),
321 msg.is_cursor_visible() && cached_line.is_partial,
322 )
323 } else {
324 (false, false)
325 }
326 } else {
327 (false, false)
328 };
329
330 let persistent_cursor =
331 is_last_line && !is_typing && self.persistent_cursor.is_visible();
332
333 result.push((
334 cached_line.content.clone(),
335 cached_line.visible_chars,
336 is_typing,
337 cursor_visible,
338 persistent_cursor,
339 ));
340 }
341 }
342
343 while result.len() < window_height {
345 result.push((String::new(), 0, false, false, false));
346 }
347
348 self.debug_log(&format!("Result: {} lines generated", result.len()));
349 result
350 }
351
352 pub fn add_message(&mut self, content: String) {
354 self.add_message_with_typewriter(content, true);
355 }
356
357 pub fn add_message_instant(&mut self, content: String) {
358 self.add_message_with_typewriter(content, false);
359 }
360
361 fn add_message_with_typewriter(&mut self, content: String, use_typewriter: bool) {
362 let line_count = content.lines().count();
363
364 let force_instant = line_count > 5 || content.len() > 200;
366
367 if force_instant {
368 log::info!(
369 "📦 Large message ({} lines) - forcing instant display",
370 line_count
371 );
372 }
373 if content.lines().count() > 3 {
375 log::info!("📦 Adding large message: {} lines", content.lines().count());
376 }
377
378 Self::log_to_file(&content);
379
380 if self.messages.len() >= self.config.max_messages {
382 self.messages.remove(0);
383 self.cache_dirty = true;
384 }
385
386 let typewriter_delay = if use_typewriter && !force_instant {
387 self.config.typewriter_delay
388 } else {
389 Duration::from_millis(0) };
391
392 let mut message = Message::new(content, typewriter_delay);
393
394 message.calculate_wrapped_line_count(&self.viewport);
396
397 log::info!(
398 "📝 New message: {} lines (typewriter: {})",
399 message.line_count,
400 use_typewriter
401 );
402
403 self.messages.push(message);
404 self.cache_dirty = true;
405
406 self.rebuild_line_cache();
408
409 if self.viewport.is_auto_scroll_enabled() {
411 let content_height = self.line_cache.len();
412 let window_height = self.viewport.window_height();
413
414 if content_height > window_height {
415 let target_offset = content_height - window_height;
416 self.viewport.set_scroll_offset_direct_silent(target_offset);
417
418 log::info!(
419 "📜 Auto-scroll: offset {} (content: {}, window: {})",
420 target_offset,
421 content_height,
422 window_height
423 );
424 }
425 }
426 }
427
428 pub fn update_typewriter(&mut self) {
430 self.persistent_cursor.update_blink();
431
432 if self.config.typewriter_delay.as_millis() == 0 {
433 return;
434 }
435
436 let mut needs_rebuild = false;
437
438 if let Some(last_message) = self.messages.last_mut() {
439 let total_length = last_message.content.graphemes(true).count();
440
441 if let Some(ref mut cursor) = last_message.typewriter_cursor {
442 cursor.update_blink();
443 }
444
445 if last_message.current_length < total_length {
446 let elapsed = last_message.timestamp.elapsed();
447
448 if elapsed >= self.config.typewriter_delay {
449 let old_length = last_message.current_length;
450
451 let chars_to_add = if self.config.typewriter_delay.as_millis() <= 5 {
452 let ratio = elapsed.as_millis() as f64
453 / self.config.typewriter_delay.as_millis() as f64;
454 ratio.floor().max(1.0) as usize
455 } else {
456 1
457 };
458
459 let new_length = (last_message.current_length + chars_to_add).min(total_length);
460 last_message.current_length = new_length;
461 last_message.timestamp = Instant::now();
462
463 let chars_since_last_rebuild = new_length - old_length;
464 let next_chars = last_message
465 .content
466 .chars()
467 .skip(old_length)
468 .take(chars_to_add)
469 .collect::<String>();
470
471 if next_chars.contains('\n') {
473 needs_rebuild = true;
474 log::trace!("🔄 Typewriter crossed newline boundary!");
475 } else if chars_since_last_rebuild > 50 {
476 needs_rebuild = true;
478 }
479
480 self.cache_dirty = true;
481
482 if new_length == total_length {
483 last_message.typewriter_cursor = None;
484 needs_rebuild = true;
485
486 self.viewport.enable_auto_scroll_silent();
488 self.viewport.scroll_to_bottom();
489 }
490 }
491 }
492 }
493
494 if needs_rebuild && self.cache_dirty {
496 self.rebuild_line_cache();
497 }
498 }
499
500 pub fn handle_scroll(&mut self, direction: ScrollDirection, amount: usize) {
502 match direction {
503 ScrollDirection::Up => self.viewport.scroll_up(amount.max(1)),
504 ScrollDirection::Down => self.viewport.scroll_down(amount.max(1)),
505 ScrollDirection::PageUp => self.viewport.page_up(),
506 ScrollDirection::PageDown => self.viewport.page_down(),
507 ScrollDirection::ToTop => self.viewport.scroll_to_top(),
508 ScrollDirection::ToBottom => self.viewport.scroll_to_bottom(),
509 }
510 }
511
512 pub fn handle_resize(&mut self, width: u16, height: u16) -> bool {
513 let changed = self.viewport.update_terminal_size(width, height);
514
515 if changed {
516 for message in &mut self.messages {
517 message.calculate_wrapped_line_count(&self.viewport);
518 }
519 self.cache_dirty = true;
520 self.viewport.force_auto_scroll();
521 }
522
523 changed
524 }
525
526 pub fn clear_messages(&mut self) {
527 self.messages.clear();
528 self.line_cache.clear();
529 self.cache_dirty = false;
530 self.viewport.update_content_height_silent(0);
531 self.viewport.force_auto_scroll();
532 self.persistent_cursor.show_cursor();
533 }
534
535 pub fn create_output_widget_for_rendering(&mut self) -> RenderData<'_> {
536 let messages = self.get_visible_messages();
537 (
538 messages,
539 self.config.clone(),
540 self.viewport.output_area(),
541 &self.persistent_cursor,
542 )
543 }
544
545 pub fn update_config(&mut self, new_config: &Config) {
546 self.config = new_config.clone();
547 self.persistent_cursor = UiCursor::from_config(new_config, CursorKind::Output);
548 self.cache_dirty = true;
549
550 if self.messages.len() > self.config.max_messages {
551 let excess = self.messages.len() - self.config.max_messages;
552 self.messages.drain(0..excess);
553 self.cache_dirty = true;
554 }
555 }
556
557 pub fn viewport(&self) -> &Viewport {
559 &self.viewport
560 }
561 pub fn viewport_mut(&mut self) -> &mut Viewport {
562 &mut self.viewport
563 }
564 pub fn get_messages_count(&self) -> usize {
565 self.messages.len()
566 }
567 pub fn get_line_count(&self) -> usize {
568 if self.cache_dirty {
569 self.messages.iter().map(|m| m.line_count).sum()
570 } else {
571 self.line_cache.len()
572 }
573 }
574
575 pub fn debug_scroll_status(&self) -> String {
576 format!(
577 "Scroll: offset={}, lines={}, window={}, auto={}, msgs={}, cache={}",
578 self.viewport.scroll_offset(),
579 self.viewport.content_height(),
580 self.viewport.window_height(),
581 self.viewport.is_auto_scroll_enabled(),
582 self.messages.len(),
583 self.line_cache.len()
584 )
585 }
586
587 pub fn handle_viewport_event(&mut self, event: ViewportEvent) -> bool {
589 let changed = self.viewport.handle_event(event);
590 if changed {
591 for message in &mut self.messages {
592 message.calculate_wrapped_line_count(&self.viewport);
593 }
594 self.cache_dirty = true;
595 }
596 changed
597 }
598
599 pub fn get_content_height(&self) -> usize {
600 self.viewport.content_height()
601 }
602 pub fn get_window_height(&self) -> usize {
603 self.viewport.window_height()
604 }
605
606 pub fn log(&mut self, level: &str, message: &str) {
607 let log_message = format!("[{}] {}", level, message);
608 self.add_message(log_message);
609 }
610
611 fn log_to_file(content: &str) {
613 if content.starts_with("__") || content.trim().is_empty() {
614 return;
615 }
616
617 let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
618 let log_line = format!("[{}] {}\n", timestamp, content);
619
620 if let Ok(exe_path) = std::env::current_exe() {
621 if let Some(base_dir) = exe_path.parent() {
622 let log_path = base_dir.join(".rss").join("rush.logs");
623 let _ = std::fs::create_dir_all(log_path.parent().unwrap());
624 let _ = std::fs::OpenOptions::new()
625 .create(true)
626 .append(true)
627 .open(&log_path)
628 .and_then(|mut file| {
629 use std::io::Write;
630 file.write_all(log_line.as_bytes())
631 });
632 }
633 }
634 }
635
636 fn log_startup() {
637 let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
638 let version = crate::core::constants::VERSION;
639 let startup_line = format!(
640 "[{}] === Rush Sync Server v{} Started ===\n",
641 timestamp, version
642 );
643
644 if let Ok(exe_path) = std::env::current_exe() {
645 if let Some(base_dir) = exe_path.parent() {
646 let log_path = base_dir.join(".rss").join("rush.logs");
647 let _ = std::fs::create_dir_all(log_path.parent().unwrap());
648 let _ = std::fs::OpenOptions::new()
649 .create(true)
650 .append(true)
651 .open(&log_path)
652 .and_then(|mut file| {
653 use std::io::Write;
654 file.write_all(startup_line.as_bytes())
655 });
656 }
657 }
658 }
659}
660
661fn clean_ansi_codes(message: &str) -> String {
663 String::from_utf8_lossy(&strip(message.as_bytes()).unwrap_or_default()).into_owned()
664}
665
666fn clean_message_for_display(message: &str) -> String {
667 clean_ansi_codes(message)
668 .replace("__CONFIRM_EXIT__", "")
669 .replace("__CLEAR__", "")
670 .trim()
671 .to_string()
672}
673
674fn parse_message_parts(message: &str) -> Vec<(String, bool)> {
675 let mut parts = Vec::new();
676 let mut chars = message.char_indices().peekable();
677 let mut start = 0;
678
679 while let Some((i, c)) = chars.peek().cloned() {
680 if c == '[' {
681 if start < i {
682 let text = &message[start..i];
683 if !text.trim().is_empty() {
684 parts.push((text.to_owned(), false));
685 }
686 }
687
688 if let Some(end_idx) = message[i..].find(']') {
689 let end = i + end_idx + 1;
690 parts.push((message[i..end].to_owned(), true));
691 start = end;
692 while let Some(&(ci, _)) = chars.peek() {
693 if ci < end {
694 chars.next();
695 } else {
696 break;
697 }
698 }
699 } else {
700 parts.push((message[i..].to_owned(), false));
701 break;
702 }
703 } else {
704 chars.next();
705 }
706 }
707
708 if start < message.len() {
709 let remaining = &message[start..];
710 if !remaining.trim().is_empty() {
711 parts.push((remaining.to_owned(), false));
712 }
713 }
714
715 if parts.is_empty() {
716 parts.push((message.to_owned(), false));
717 }
718
719 parts
720}
721
722fn get_marker_color(marker: &str) -> AppColor {
723 let display_category = marker
724 .trim_start_matches('[')
725 .trim_end_matches(']')
726 .trim_start_matches("cat:")
727 .to_lowercase();
728
729 if AppColor::from_any(&display_category).to_name() != "gray" {
730 return AppColor::from_any(&display_category);
731 }
732
733 let mapped_category = crate::i18n::get_color_category_for_display(&display_category);
734 AppColor::from_any(mapped_category)
735}
736
737pub fn create_output_widget<'a>(
739 messages: &'a [(String, usize, bool, bool, bool)],
740 layout_area: crate::ui::viewport::LayoutArea,
741 config: &'a Config,
742 cursor_state: &'a UiCursor,
743) -> Paragraph<'a> {
744 let max_lines = layout_area.height as usize;
745
746 if max_lines == 0 || layout_area.width == 0 {
747 return Paragraph::new(vec![Line::from(vec![Span::raw("⚠️ INVALID LAYOUT")])]).block(
748 Block::default()
749 .borders(Borders::NONE)
750 .style(Style::default().bg(config.theme.output_bg.into())),
751 );
752 }
753
754 let safe_max_lines = max_lines.min(1000);
755 let mut lines = Vec::new();
756
757 if messages.is_empty() {
758 let empty_lines = vec![Line::from(vec![Span::raw("")]); safe_max_lines];
759 return Paragraph::new(empty_lines)
760 .block(
761 Block::default()
762 .borders(Borders::NONE)
763 .style(Style::default().bg(config.theme.output_bg.into())),
764 )
765 .wrap(Wrap { trim: true });
766 }
767
768 for (
769 message_idx,
770 (message, current_length, is_typing, msg_cursor_visible, persistent_cursor_visible),
771 ) in messages.iter().enumerate()
772 {
773 let is_last_message = message_idx == messages.len() - 1;
774
775 if message.is_empty() {
776 if *persistent_cursor_visible {
777 lines.push(Line::from(vec![cursor_state.create_cursor_span(config)]));
778 } else {
779 lines.push(Line::from(vec![Span::raw("")]));
780 }
781 continue;
782 }
783
784 let clean_message = clean_message_for_display(message);
785 let message_lines: Vec<&str> = clean_message.lines().collect();
786
787 if message_lines.is_empty() {
788 lines.push(Line::from(vec![Span::raw("")]));
789 } else {
790 for (line_idx, line_content) in message_lines.iter().enumerate() {
791 if lines.len() >= safe_max_lines {
792 break;
793 }
794
795 let is_last_line = line_idx == message_lines.len() - 1;
796 let visible_chars = if is_last_message && is_last_line {
797 let chars_before_this_line: usize = message_lines
798 .iter()
799 .take(line_idx)
800 .map(|l| l.graphemes(true).count() + 1)
801 .sum();
802 let available_for_this_line =
803 current_length.saturating_sub(chars_before_this_line);
804 available_for_this_line.min(line_content.graphemes(true).count())
805 } else {
806 line_content.graphemes(true).count()
807 };
808
809 let message_parts = parse_message_parts(line_content);
810 let mut spans = Vec::new();
811 let mut chars_used = 0;
812
813 for (part_text, is_marker) in message_parts {
814 let part_chars = part_text.graphemes(true).count();
815 let part_style = if is_marker {
816 Style::default().fg(get_marker_color(&part_text).into())
817 } else {
818 Style::default().fg(config.theme.output_text.into())
819 };
820
821 if chars_used >= visible_chars {
822 break;
823 }
824
825 let chars_needed = visible_chars - chars_used;
826 if chars_needed >= part_chars {
827 spans.push(Span::styled(part_text, part_style));
828 chars_used += part_chars;
829 } else {
830 let graphemes: Vec<&str> = part_text.graphemes(true).collect();
831 spans.push(Span::styled(
832 graphemes
833 .iter()
834 .take(chars_needed)
835 .copied()
836 .collect::<String>(),
837 part_style,
838 ));
839 break;
840 }
841 }
842
843 if is_last_message
844 && is_last_line
845 && ((*is_typing && *msg_cursor_visible)
846 || (!*is_typing && *persistent_cursor_visible))
847 {
848 spans.push(cursor_state.create_cursor_span(config));
849 }
850
851 if spans.is_empty() {
852 spans.push(Span::raw(""));
853 }
854
855 lines.push(Line::from(spans));
856 }
857 }
858
859 if lines.len() >= safe_max_lines {
860 break;
861 }
862 }
863
864 while lines.len() < safe_max_lines {
865 lines.push(Line::from(vec![Span::raw("")]));
866 }
867
868 lines.truncate(safe_max_lines);
869
870 if lines.is_empty() {
871 lines.push(Line::from(vec![Span::raw("ERROR: Empty buffer")]));
872 }
873
874 Paragraph::new(lines)
875 .block(
876 Block::default()
877 .borders(Borders::NONE)
878 .style(Style::default().bg(config.theme.output_bg.into())),
879 )
880 .wrap(Wrap { trim: true })
881}