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, typewriter_cursor) = if typewriter_delay.as_millis() == 0 {
40 (content.graphemes(true).count(), None)
41 } else {
42 (0, Some(UiCursor::for_typewriter()))
43 };
44
45 Self {
46 content,
47 current_length: initial_length,
48 timestamp: Instant::now(),
49 line_count: 1,
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 if clean_content.is_empty() {
57 self.line_count = 1;
58 return;
59 }
60
61 let effective_width = (viewport.output_area().width as usize)
62 .saturating_sub(2)
63 .max(10);
64 let raw_lines: Vec<&str> = clean_content.lines().collect();
65
66 let lines_to_process = if clean_content.ends_with('\n') {
67 let mut lines = raw_lines;
68 lines.push("");
69 lines
70 } else if raw_lines.is_empty() {
71 vec![""]
72 } else {
73 raw_lines
74 };
75
76 self.line_count = lines_to_process
77 .iter()
78 .map(|line| {
79 if line.is_empty() {
80 1
81 } else {
82 ((line.graphemes(true).count().saturating_sub(1)) / effective_width) + 1
83 }
84 })
85 .sum::<usize>()
86 .max(1);
87 }
88
89 pub fn is_typing(&self) -> bool {
90 self.typewriter_cursor.is_some()
91 && self.current_length < self.content.graphemes(true).count()
92 }
93 pub fn is_cursor_visible(&self) -> bool {
94 self.typewriter_cursor
95 .as_ref()
96 .is_some_and(|c| c.is_visible())
97 }
98}
99
100pub struct MessageDisplay {
101 messages: Vec<Message>,
102 line_cache: Vec<CachedLine>,
103 cache_dirty: bool,
104 config: Config,
105 viewport: Viewport,
106 persistent_cursor: UiCursor,
107}
108
109impl MessageDisplay {
110 pub fn new(config: &Config, terminal_width: u16, terminal_height: u16) -> Self {
111 Self::log_startup();
112 Self {
113 messages: Vec::with_capacity(config.max_messages),
114 line_cache: Vec::new(),
115 cache_dirty: true,
116 config: config.clone(),
117 viewport: Viewport::new(terminal_width, terminal_height),
118 persistent_cursor: UiCursor::from_config(config, CursorKind::Output),
119 }
120 }
121
122 fn rebuild_line_cache(&mut self) {
123 self.line_cache.clear();
124 let effective_width = (self.viewport.output_area().width as usize)
125 .saturating_sub(2)
126 .max(10);
127
128 for (msg_idx, message) in self.messages.iter().enumerate() {
129 let visible_content = if message.is_typing() {
130 let graphemes: Vec<&str> = message.content.graphemes(true).collect();
131 graphemes
132 .iter()
133 .take(message.current_length)
134 .copied()
135 .collect::<String>()
136 } else {
137 message.content.clone()
138 };
139
140 let clean_content = clean_message_for_display(&visible_content);
141 let lines: Vec<String> = if clean_content.is_empty() {
142 vec![String::new()]
143 } else {
144 let mut lines: Vec<String> = clean_content.lines().map(|s| s.to_string()).collect();
145 if clean_content.ends_with('\n') {
146 lines.push(String::new());
147 }
148 if lines.is_empty() {
149 lines.push(String::new());
150 }
151 lines
152 };
153
154 for (line_idx, raw_line) in lines.iter().enumerate() {
155 if raw_line.is_empty() {
156 self.line_cache.push(CachedLine {
157 content: String::new(),
158 message_index: msg_idx,
159 is_partial: false,
160 visible_chars: 0,
161 });
162 } else {
163 let graphemes: Vec<&str> = raw_line.graphemes(true).collect();
164 let mut start = 0;
165 while start < graphemes.len() {
166 let end = (start + effective_width).min(graphemes.len());
167 let wrapped_line = graphemes[start..end].join("");
168 let is_last_chunk = end == graphemes.len();
169 let is_last_line = line_idx == lines.len() - 1;
170
171 self.line_cache.push(CachedLine {
172 content: wrapped_line.clone(),
173 message_index: msg_idx,
174 is_partial: message.is_typing() && is_last_line && is_last_chunk,
175 visible_chars: wrapped_line.graphemes(true).count(),
176 });
177 start = end;
178 }
179 }
180 }
181 }
182
183 if let Some(last_msg) = self.messages.last() {
184 if !last_msg.is_typing() {
185 self.line_cache.push(CachedLine {
186 content: String::new(),
187 message_index: self.messages.len(),
188 is_partial: false,
189 visible_chars: 0,
190 });
191 }
192 }
193
194 self.cache_dirty = false;
195 self.viewport
196 .update_content_height_silent(self.line_cache.len());
197 }
198
199 pub fn get_visible_messages(&mut self) -> Vec<(String, usize, bool, bool, bool)> {
200 if self.cache_dirty {
201 self.rebuild_line_cache();
202 }
203
204 let window_height = self.viewport.window_height();
205 let scroll_offset = self.viewport.scroll_offset();
206 let available_lines = self.line_cache.len().saturating_sub(scroll_offset);
207 let lines_to_show = available_lines.min(window_height);
208 let visible_start = scroll_offset;
209 let visible_end = scroll_offset + lines_to_show;
210
211 let mut result = Vec::new();
212 if self.line_cache.is_empty() {
213 result.push((
214 String::new(),
215 0,
216 false,
217 false,
218 self.persistent_cursor.is_visible(),
219 ));
220 return result;
221 }
222
223 for line_idx in visible_start..visible_end {
224 if let Some(cached_line) = self.line_cache.get(line_idx) {
225 let msg_idx = cached_line.message_index;
226 let is_last_line = line_idx == self.line_cache.len() - 1;
227
228 let (is_typing, cursor_visible) = if msg_idx < self.messages.len() {
229 self.messages.get(msg_idx).map_or((false, false), |msg| {
230 (
231 cached_line.is_partial && msg.is_typing(),
232 msg.is_cursor_visible() && cached_line.is_partial,
233 )
234 })
235 } else {
236 (false, false)
237 };
238
239 let persistent_cursor =
240 is_last_line && !is_typing && self.persistent_cursor.is_visible();
241 result.push((
242 cached_line.content.clone(),
243 cached_line.visible_chars,
244 is_typing,
245 cursor_visible,
246 persistent_cursor,
247 ));
248 }
249 }
250
251 while result.len() < window_height {
252 result.push((String::new(), 0, false, false, false));
253 }
254 result
255 }
256
257 pub fn add_message(&mut self, content: String) {
258 self.add_message_with_typewriter(content, true);
259 }
260 pub fn add_message_instant(&mut self, content: String) {
261 self.add_message_with_typewriter(content, false);
262 }
263
264 fn add_message_with_typewriter(&mut self, content: String, use_typewriter: bool) {
265 let line_count = content.lines().count();
266 let force_instant = line_count > 5 || content.len() > 200;
267
268 Self::log_to_file(&content);
269
270 if self.messages.len() >= self.config.max_messages {
271 self.messages.remove(0);
272 self.cache_dirty = true;
273 }
274
275 let typewriter_delay = if use_typewriter && !force_instant {
276 self.config.typewriter_delay
277 } else {
278 Duration::from_millis(0)
279 };
280
281 let mut message = Message::new(content, typewriter_delay);
282 message.calculate_wrapped_line_count(&self.viewport);
283 self.messages.push(message);
284 self.cache_dirty = true;
285 self.rebuild_line_cache();
286
287 if force_instant || !use_typewriter {
288 self.viewport.enable_auto_scroll_silent();
289 }
290
291 if self.viewport.is_auto_scroll_enabled() {
292 let content_height = self.line_cache.len();
293 let window_height = self.viewport.window_height();
294 if content_height > window_height {
295 self.viewport
296 .set_scroll_offset_direct_silent(content_height - window_height);
297 }
298 }
299 }
300
301 pub fn update_typewriter(&mut self) {
302 self.persistent_cursor.update_blink();
303 if self.config.typewriter_delay.as_millis() == 0 {
304 return;
305 }
306
307 let mut needs_rebuild = false;
308 if let Some(last_message) = self.messages.last_mut() {
309 let total_length = last_message.content.graphemes(true).count();
310 if let Some(ref mut cursor) = last_message.typewriter_cursor {
311 cursor.update_blink();
312 }
313
314 if last_message.current_length < total_length {
315 let elapsed = last_message.timestamp.elapsed();
316 if elapsed >= self.config.typewriter_delay {
317 let old_length = last_message.current_length;
318 let chars_to_add = if self.config.typewriter_delay.as_millis() <= 5 {
319 ((elapsed.as_millis() as f64
320 / self.config.typewriter_delay.as_millis() as f64)
321 .floor()
322 .max(1.0)) as usize
323 } else {
324 1
325 };
326
327 let new_length = (last_message.current_length + chars_to_add).min(total_length);
328 last_message.current_length = new_length;
329 last_message.timestamp = Instant::now();
330
331 let next_chars = last_message
332 .content
333 .chars()
334 .skip(old_length)
335 .take(chars_to_add)
336 .collect::<String>();
337 if next_chars.contains('\n') || (new_length - old_length) > 50 {
338 needs_rebuild = true;
339 }
340
341 self.cache_dirty = true;
342 if new_length == total_length {
343 last_message.typewriter_cursor = None;
344 needs_rebuild = true;
345 self.viewport.enable_auto_scroll_silent();
346 self.viewport.scroll_to_bottom();
347 }
348 }
349 }
350 }
351 if needs_rebuild && self.cache_dirty {
352 self.rebuild_line_cache();
353 }
354 }
355
356 pub fn handle_scroll(&mut self, direction: ScrollDirection, amount: usize) {
357 match direction {
358 ScrollDirection::Up => self.viewport.scroll_up(amount.max(1)),
359 ScrollDirection::Down => self.viewport.scroll_down(amount.max(1)),
360 ScrollDirection::PageUp => self.viewport.page_up(),
361 ScrollDirection::PageDown => self.viewport.page_down(),
362 ScrollDirection::ToTop => self.viewport.scroll_to_top(),
363 ScrollDirection::ToBottom => self.viewport.scroll_to_bottom(),
364 }
365 }
366
367 pub fn handle_resize(&mut self, width: u16, height: u16) -> bool {
368 let changed = self.viewport.update_terminal_size(width, height);
369 if changed {
370 for message in &mut self.messages {
371 message.calculate_wrapped_line_count(&self.viewport);
372 }
373 self.cache_dirty = true;
374 self.viewport.force_auto_scroll();
375 }
376 changed
377 }
378
379 pub fn clear_messages(&mut self) {
380 self.messages.clear();
381 self.line_cache.clear();
382 self.cache_dirty = false;
383 self.viewport.update_content_height_silent(0);
384 self.viewport.force_auto_scroll();
385 self.persistent_cursor.show_cursor();
386 }
387
388 pub fn create_output_widget_for_rendering(&mut self) -> RenderData<'_> {
389 (
390 self.get_visible_messages(),
391 self.config.clone(),
392 self.viewport.output_area(),
393 &self.persistent_cursor,
394 )
395 }
396
397 pub fn update_config(&mut self, new_config: &Config) {
398 self.config = new_config.clone();
399 self.persistent_cursor = UiCursor::from_config(new_config, CursorKind::Output);
400 self.cache_dirty = true;
401 if self.messages.len() > self.config.max_messages {
402 let excess = self.messages.len() - self.config.max_messages;
403 self.messages.drain(0..excess);
404 self.cache_dirty = true;
405 }
406 }
407
408 pub fn viewport(&self) -> &Viewport {
409 &self.viewport
410 }
411 pub fn viewport_mut(&mut self) -> &mut Viewport {
412 &mut self.viewport
413 }
414 pub fn get_messages_count(&self) -> usize {
415 self.messages.len()
416 }
417 pub fn get_line_count(&self) -> usize {
418 if self.cache_dirty {
419 self.messages.iter().map(|m| m.line_count).sum()
420 } else {
421 self.line_cache.len()
422 }
423 }
424 pub fn get_content_height(&self) -> usize {
425 self.viewport.content_height()
426 }
427 pub fn get_window_height(&self) -> usize {
428 self.viewport.window_height()
429 }
430
431 pub fn debug_scroll_status(&self) -> String {
432 format!(
433 "Scroll: offset={}, lines={}, window={}, auto={}, msgs={}, cache={}",
434 self.viewport.scroll_offset(),
435 self.viewport.content_height(),
436 self.viewport.window_height(),
437 self.viewport.is_auto_scroll_enabled(),
438 self.messages.len(),
439 self.line_cache.len()
440 )
441 }
442
443 pub fn handle_viewport_event(&mut self, event: ViewportEvent) -> bool {
444 let changed = self.viewport.handle_event(event);
445 if changed {
446 for message in &mut self.messages {
447 message.calculate_wrapped_line_count(&self.viewport);
448 }
449 self.cache_dirty = true;
450 }
451 changed
452 }
453
454 pub fn log(&mut self, level: &str, message: &str) {
455 self.add_message(format!("[{}] {}", level, message));
456 }
457
458 fn log_to_file(content: &str) {
459 if content.starts_with("__") || content.trim().is_empty() {
460 return;
461 }
462 if let Ok(exe_path) = std::env::current_exe() {
463 if let Some(base_dir) = exe_path.parent() {
464 let log_path = base_dir.join(".rss").join("rush.logs");
465 let _ = std::fs::create_dir_all(log_path.parent().unwrap());
466 let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
467 let log_line = format!("[{}] {}\n", timestamp, content);
468 let _ = std::fs::OpenOptions::new()
469 .create(true)
470 .append(true)
471 .open(&log_path)
472 .and_then(|mut file| {
473 use std::io::Write;
474 file.write_all(log_line.as_bytes())
475 });
476 }
477 }
478 }
479
480 fn log_startup() {
481 if let Ok(exe_path) = std::env::current_exe() {
482 if let Some(base_dir) = exe_path.parent() {
483 let log_path = base_dir.join(".rss").join("rush.logs");
484 let _ = std::fs::create_dir_all(log_path.parent().unwrap());
485 let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
486 let version = crate::core::constants::VERSION;
487 let startup_line = format!(
488 "[{}] === Rush Sync Server v{} Started ===\n",
489 timestamp, version
490 );
491 let _ = std::fs::OpenOptions::new()
492 .create(true)
493 .append(true)
494 .open(&log_path)
495 .and_then(|mut file| {
496 use std::io::Write;
497 file.write_all(startup_line.as_bytes())
498 });
499 }
500 }
501 }
502}
503
504fn clean_ansi_codes(message: &str) -> String {
506 String::from_utf8_lossy(&strip(message.as_bytes()).unwrap_or_default()).into_owned()
507}
508
509fn clean_message_for_display(message: &str) -> String {
510 use crate::core::constants::*;
511 clean_ansi_codes(message)
512 .replace(SIG_CONFIRM_EXIT, "")
513 .replace(SIG_CLEAR, "")
514 .trim()
515 .to_string()
516}
517
518fn parse_message_parts(message: &str) -> Vec<(String, bool)> {
519 let mut parts = Vec::new();
520 let mut chars = message.char_indices().peekable();
521 let mut start = 0;
522
523 while let Some((i, c)) = chars.peek().cloned() {
524 if c == '[' {
525 if start < i {
526 let text = &message[start..i];
527 if !text.trim().is_empty() {
528 parts.push((text.to_owned(), false));
529 }
530 }
531 if let Some(end_idx) = message[i..].find(']') {
532 let end = i + end_idx + 1;
533 parts.push((message[i..end].to_owned(), true));
534 start = end;
535 while let Some(&(ci, _)) = chars.peek() {
536 if ci < end {
537 chars.next();
538 } else {
539 break;
540 }
541 }
542 } else {
543 parts.push((message[i..].to_owned(), false));
544 break;
545 }
546 } else {
547 chars.next();
548 }
549 }
550
551 if start < message.len() {
552 let remaining = &message[start..];
553 if !remaining.trim().is_empty() {
554 parts.push((remaining.to_owned(), false));
555 }
556 }
557 if parts.is_empty() {
558 parts.push((message.to_owned(), false));
559 }
560 parts
561}
562
563fn get_marker_color(marker: &str) -> AppColor {
564 let display_text = marker.trim_start_matches('[').trim_end_matches(']');
565 AppColor::from_display_text(display_text)
566}
567
568pub fn create_output_widget<'a>(
569 messages: &'a [(String, usize, bool, bool, bool)],
570 layout_area: crate::ui::viewport::LayoutArea,
571 config: &'a Config,
572 cursor_state: &'a UiCursor,
573) -> Paragraph<'a> {
574 let max_lines = layout_area.height as usize;
575 if max_lines == 0 || layout_area.width == 0 {
576 return Paragraph::new(vec![Line::from(vec![Span::raw(get_translation(
577 "screen.render.invalid_layout",
578 &[],
579 ))])])
580 .block(
581 Block::default()
582 .borders(Borders::NONE)
583 .style(Style::default().bg(config.theme.output_bg.into())),
584 );
585 }
586
587 let safe_max_lines = max_lines.min(1000);
588 let mut lines = Vec::new();
589
590 if messages.is_empty() {
591 return Paragraph::new(vec![Line::from(vec![Span::raw("")]); safe_max_lines])
592 .block(
593 Block::default()
594 .borders(Borders::NONE)
595 .style(Style::default().bg(config.theme.output_bg.into())),
596 )
597 .wrap(Wrap { trim: true });
598 }
599
600 for (
601 message_idx,
602 (message, current_length, is_typing, msg_cursor_visible, persistent_cursor_visible),
603 ) in messages.iter().enumerate()
604 {
605 let is_last_message = message_idx == messages.len() - 1;
606
607 if message.is_empty() {
608 if *persistent_cursor_visible {
609 lines.push(Line::from(vec![cursor_state.create_cursor_span(config)]));
610 } else {
611 lines.push(Line::from(vec![Span::raw("")]));
612 }
613 continue;
614 }
615
616 let clean_message = clean_message_for_display(message);
617 let message_lines: Vec<&str> = clean_message.lines().collect();
618
619 if message_lines.is_empty() {
620 lines.push(Line::from(vec![Span::raw("")]));
621 } else {
622 for (line_idx, line_content) in message_lines.iter().enumerate() {
623 if lines.len() >= safe_max_lines {
624 break;
625 }
626
627 let is_last_line = line_idx == message_lines.len() - 1;
628 let visible_chars = if is_last_message && is_last_line {
629 let chars_before_this_line: usize = message_lines
630 .iter()
631 .take(line_idx)
632 .map(|l| l.graphemes(true).count() + 1)
633 .sum();
634 current_length
635 .saturating_sub(chars_before_this_line)
636 .min(line_content.graphemes(true).count())
637 } else {
638 line_content.graphemes(true).count()
639 };
640
641 let message_parts = parse_message_parts(line_content);
642 let mut spans = Vec::new();
643 let mut chars_used = 0;
644
645 for (part_text, is_marker) in message_parts {
646 let part_chars = part_text.graphemes(true).count();
647 let part_style = if is_marker {
648 Style::default().fg(get_marker_color(&part_text).into())
649 } else {
650 Style::default().fg(config.theme.output_text.into())
651 };
652
653 if chars_used >= visible_chars {
654 break;
655 }
656 let chars_needed = visible_chars - chars_used;
657 if chars_needed >= part_chars {
658 spans.push(Span::styled(part_text, part_style));
659 chars_used += part_chars;
660 } else {
661 let graphemes: Vec<&str> = part_text.graphemes(true).collect();
662 spans.push(Span::styled(
663 graphemes
664 .iter()
665 .take(chars_needed)
666 .copied()
667 .collect::<String>(),
668 part_style,
669 ));
670 break;
671 }
672 }
673
674 if is_last_message
675 && is_last_line
676 && ((*is_typing && *msg_cursor_visible)
677 || (!*is_typing && *persistent_cursor_visible))
678 {
679 spans.push(cursor_state.create_cursor_span(config));
680 }
681 if spans.is_empty() {
682 spans.push(Span::raw(""));
683 }
684 lines.push(Line::from(spans));
685 }
686 }
687 if lines.len() >= safe_max_lines {
688 break;
689 }
690 }
691
692 while lines.len() < safe_max_lines {
693 lines.push(Line::from(vec![Span::raw("")]));
694 }
695 lines.truncate(safe_max_lines);
696 if lines.is_empty() {
697 lines.push(Line::from(vec![Span::raw(get_translation(
698 "screen.render.empty_buffer_error",
699 &[],
700 ))]));
701 }
702
703 Paragraph::new(lines)
704 .block(
705 Block::default()
706 .borders(Borders::NONE)
707 .style(Style::default().bg(config.theme.output_bg.into())),
708 )
709 .wrap(Wrap { trim: true })
710}