rush_sync_server/output/
output.rs1use crate::core::prelude::*;
3use crate::ui::color::AppColor;
4use ratatui::{
5 style::Style,
6 text::{Line, Span},
7 widgets::{Block, Borders, Paragraph, Wrap},
8};
9use strip_ansi_escapes::strip;
10use unicode_segmentation::UnicodeSegmentation;
11
12fn clean_ansi_codes(message: &str) -> String {
14 String::from_utf8_lossy(&strip(message.as_bytes()).unwrap_or_default()).into_owned()
15}
16
17fn clean_message_for_display(message: &str) -> String {
19 clean_ansi_codes(message)
20 .replace("__CONFIRM_EXIT__", "")
21 .replace("__CLEAR__", "")
22 .trim()
23 .to_string()
24}
25
26fn parse_message_parts(message: &str) -> Vec<(String, bool)> {
29 let mut parts = Vec::new();
30 let mut chars = message.char_indices().peekable();
31 let mut start = 0;
32
33 while let Some((i, c)) = chars.peek().cloned() {
34 if c == '[' {
35 if start < i {
37 let text = &message[start..i];
38 if !text.trim().is_empty() {
39 parts.push((text.to_owned(), false));
40 }
41 }
42
43 if let Some(end_idx) = message[i..].find(']') {
45 let end = i + end_idx + 1;
46 parts.push((message[i..end].to_owned(), true));
47 start = end;
48 while let Some(&(ci, _)) = chars.peek() {
49 if ci < end {
50 chars.next();
51 } else {
52 break;
53 }
54 }
55 } else {
56 parts.push((message[i..].to_owned(), false));
57 break;
58 }
59 } else {
60 chars.next();
61 }
62 }
63
64 if start < message.len() {
65 let remaining = &message[start..];
66 if !remaining.trim().is_empty() {
67 parts.push((remaining.to_owned(), false));
68 }
69 }
70
71 if parts.is_empty() {
72 parts.push((message.to_owned(), false));
73 }
74
75 parts
76}
77
78fn get_marker_color(marker: &str) -> AppColor {
80 let display_category = marker
81 .trim_start_matches('[')
82 .trim_end_matches(']')
83 .trim_start_matches("cat:")
84 .to_lowercase();
85
86 if AppColor::from_any(&display_category).to_name() != "gray" {
88 return AppColor::from_any(&display_category);
89 }
90
91 let mapped_category = crate::i18n::get_color_category_for_display(&display_category);
93 AppColor::from_any(mapped_category)
94}
95
96pub fn create_output_widget<'a>(
98 messages: &'a [(&'a String, usize)],
99 available_height: u16,
100 config: &Config,
101) -> Paragraph<'a> {
102 let mut lines = Vec::new();
103 let max_visible_messages = (available_height as usize).saturating_sub(1);
104
105 if messages.is_empty() {
106 let empty_lines = vec![Line::from(vec![Span::raw("")]); max_visible_messages];
107 return Paragraph::new(empty_lines)
108 .block(
109 Block::default()
110 .borders(Borders::NONE)
111 .style(Style::default().bg(config.theme.output_bg.into())),
112 )
113 .wrap(Wrap { trim: true });
114 }
115
116 let start_idx = messages.len().saturating_sub(max_visible_messages);
117 let visible_messages = &messages[start_idx..];
118
119 for (idx, (message, current_length)) in visible_messages.iter().enumerate() {
120 let is_last_message = idx == visible_messages.len() - 1;
121 let clean_message = clean_message_for_display(message);
122 let message_parts = parse_message_parts(&clean_message);
123
124 let mut styled_parts = Vec::new();
125 let mut total_chars = 0;
126
127 for (part_text, is_marker) in message_parts {
128 let part_chars = part_text.graphemes(true).count();
129 let part_style = if is_marker {
130 Style::default().fg(get_marker_color(&part_text).into())
131 } else {
132 Style::default().fg(config.theme.output_text.into())
133 };
134
135 styled_parts.push((part_text, part_style, part_chars));
136 total_chars += part_chars;
137 }
138
139 let visible_chars = if is_last_message {
140 (*current_length).min(total_chars)
141 } else {
142 total_chars
143 };
144
145 let mut spans = Vec::new();
146 let mut chars_used = 0;
147
148 for (part_text, part_style, part_char_count) in styled_parts {
149 if chars_used >= visible_chars {
150 break;
151 }
152
153 let chars_needed = visible_chars - chars_used;
154
155 if chars_needed >= part_char_count {
156 spans.push(Span::styled(part_text, part_style));
157 chars_used += part_char_count;
158 } else {
159 let graphemes: Vec<&str> = part_text.graphemes(true).collect();
160 spans.push(Span::styled(
161 graphemes
162 .iter()
163 .take(chars_needed)
164 .copied()
165 .collect::<String>(),
166 part_style,
167 ));
168 break;
169 }
170 }
171
172 if spans.is_empty() {
173 spans.push(Span::raw(""));
174 }
175
176 lines.push(Line::from(spans));
177 }
178
179 let remaining_space = max_visible_messages.saturating_sub(lines.len());
180 for _ in 0..remaining_space {
181 lines.push(Line::from(vec![Span::raw("")]));
182 }
183
184 Paragraph::new(lines)
185 .block(
186 Block::default()
187 .borders(Borders::NONE)
188 .style(Style::default().bg(config.theme.output_bg.into())),
189 )
190 .wrap(Wrap { trim: true })
191}
192