rush_sync_server/output/
display.rs1use crate::core::prelude::*;
6use crate::input::keyboard::KeyAction;
7use crate::output::scroll::ScrollState;
8use crate::ui::color::AppColor;
9use ratatui::{
10 style::Style,
11 text::{Line, Span},
12 widgets::{Block, Borders, Paragraph, Wrap},
13};
14use strip_ansi_escapes::strip;
15use unicode_segmentation::UnicodeSegmentation;
16
17#[derive(Debug)]
19pub struct Message {
20 pub content: String,
21 pub current_length: usize,
22 pub timestamp: Instant,
23}
24
25pub struct MessageDisplay {
27 messages: Vec<Message>,
28 config: Config,
29 pub scroll_state: ScrollState,
30}
31
32impl MessageDisplay {
33 pub fn new(config: &Config) -> Self {
35 Self {
36 messages: Vec::with_capacity(config.max_messages),
37 config: config.clone(),
38 scroll_state: ScrollState::new(),
39 }
40 }
41
42 pub fn update_config(&mut self, new_config: &Config) {
44 self.config = new_config.clone();
45
46 if self.messages.len() > self.config.max_messages {
48 let excess = self.messages.len() - self.config.max_messages;
49 self.messages.drain(0..excess);
50
51 if self.scroll_state.offset > 0 {
52 self.scroll_state.offset = self.scroll_state.offset.saturating_sub(excess);
53 }
54 } else {
55 self.messages
56 .reserve(self.config.max_messages.saturating_sub(self.messages.len()));
57 }
58
59 log::debug!(
60 "MessageDisplay config updated: max_messages = {}, typewriter_delay = {}ms",
61 self.config.max_messages,
62 self.config.typewriter_delay.as_millis()
63 );
64 }
65
66 pub fn clear_messages(&mut self) {
68 self.messages.clear();
69 self.scroll_state.force_auto_scroll();
70 }
71
72 pub fn add_message(&mut self, content: String) {
74 if self.messages.len() >= self.config.max_messages {
76 self.messages.remove(0);
77 }
78
79 let initial_length = if self.config.typewriter_delay.as_millis() == 0 {
81 content.graphemes(true).count()
82 } else {
83 1
84 };
85
86 self.messages.push(Message {
87 content,
88 current_length: initial_length,
89 timestamp: Instant::now(),
90 });
91
92 let total_lines = self.calculate_total_lines();
94 self.scroll_state
95 .update_dimensions(self.scroll_state.window_height, total_lines);
96 self.scroll_state.force_auto_scroll();
97
98 }
104
105 pub fn update_typewriter(&mut self) {
107 if self.config.typewriter_delay.as_millis() == 0 {
108 return;
109 }
110
111 if let Some(last_message) = self.messages.last_mut() {
112 let total_length = last_message.content.graphemes(true).count();
113
114 if last_message.current_length < total_length {
115 let elapsed = last_message.timestamp.elapsed();
116
117 if elapsed >= self.config.typewriter_delay {
118 let chars_to_add = if self.config.typewriter_delay.as_millis() <= 5 {
119 let ratio = elapsed.as_millis() as f64
120 / self.config.typewriter_delay.as_millis() as f64;
121 ratio.floor().max(1.0) as usize
122 } else {
123 1
124 };
125
126 let new_length = (last_message.current_length + chars_to_add).min(total_length);
127 last_message.current_length = new_length;
128 last_message.timestamp = Instant::now();
129 }
130 }
131 }
132 }
133
134 pub fn handle_scroll(&mut self, action: KeyAction, window_height: usize) {
136 let total_lines = self.calculate_total_lines();
137 self.scroll_state
138 .update_dimensions(window_height, total_lines);
139
140 match action {
141 KeyAction::ScrollUp => self.scroll_state.scroll_up(1),
142 KeyAction::ScrollDown => self.scroll_state.scroll_down(1),
143 KeyAction::PageUp => {
144 let scroll_amount = window_height.saturating_sub(1);
145 self.scroll_state.scroll_up(scroll_amount);
146 }
147 KeyAction::PageDown => {
148 let scroll_amount = window_height.saturating_sub(1);
149 self.scroll_state.scroll_down(scroll_amount);
150 }
151 _ => {}
152 }
153 }
154
155 fn calculate_total_lines(&self) -> usize {
156 let total = self
157 .messages
158 .iter()
159 .map(|msg| {
160 let clean_msg = clean_message_for_display(&msg.content);
161 let line_count = if clean_msg.is_empty() {
162 1
163 } else {
164 clean_msg.lines().count()
165 };
166 line_count.max(1) })
168 .sum::<usize>();
169
170 total
172 }
173
174 pub fn get_content_height(&self) -> usize {
176 self.calculate_total_lines()
177 }
178
179 pub fn get_visible_messages(&self) -> Vec<(&String, usize)> {
181 let (start, end) = self.scroll_state.get_visible_range();
182 let start = start.min(self.messages.len());
183 let end = end.min(self.messages.len());
184
185 if start >= end {
186 return Vec::new();
187 }
188
189 self.messages[start..end]
190 .iter()
191 .map(|msg| (&msg.content, msg.current_length))
192 .collect()
193 }
194
195 pub fn log(&mut self, level: &str, message: &str) {
197 let log_message = format!("[{}] {}", level, message);
198 self.add_message(log_message);
199 }
200
201 pub fn create_output_widget_for_rendering(
203 &self,
204 _available_height: u16,
205 ) -> (Vec<(String, usize)>, Config) {
206 let messages = self.get_visible_messages();
207 let messages_owned: Vec<(String, usize)> = messages
208 .into_iter()
209 .map(|(content, length)| (content.clone(), length))
210 .collect();
211 (messages_owned, self.config.clone())
212 }
213
214 pub fn get_messages_count(&self) -> usize {
216 self.messages.len()
217 }
218}
219
220fn clean_ansi_codes(message: &str) -> String {
224 String::from_utf8_lossy(&strip(message.as_bytes()).unwrap_or_default()).into_owned()
225}
226
227fn clean_message_for_display(message: &str) -> String {
229 clean_ansi_codes(message)
230 .replace("__CONFIRM_EXIT__", "")
231 .replace("__CLEAR__", "")
232 .trim()
233 .to_string()
234}
235
236fn parse_message_parts(message: &str) -> Vec<(String, bool)> {
238 let mut parts = Vec::new();
239 let mut chars = message.char_indices().peekable();
240 let mut start = 0;
241
242 while let Some((i, c)) = chars.peek().cloned() {
243 if c == '[' {
244 if start < i {
245 let text = &message[start..i];
246 if !text.trim().is_empty() {
247 parts.push((text.to_owned(), false));
248 }
249 }
250
251 if let Some(end_idx) = message[i..].find(']') {
252 let end = i + end_idx + 1;
253 parts.push((message[i..end].to_owned(), true));
254 start = end;
255 while let Some(&(ci, _)) = chars.peek() {
256 if ci < end {
257 chars.next();
258 } else {
259 break;
260 }
261 }
262 } else {
263 parts.push((message[i..].to_owned(), false));
264 break;
265 }
266 } else {
267 chars.next();
268 }
269 }
270
271 if start < message.len() {
272 let remaining = &message[start..];
273 if !remaining.trim().is_empty() {
274 parts.push((remaining.to_owned(), false));
275 }
276 }
277
278 if parts.is_empty() {
279 parts.push((message.to_owned(), false));
280 }
281
282 parts
283}
284
285fn get_marker_color(marker: &str) -> AppColor {
287 let display_category = marker
288 .trim_start_matches('[')
289 .trim_end_matches(']')
290 .trim_start_matches("cat:")
291 .to_lowercase();
292
293 if AppColor::from_any(&display_category).to_name() != "gray" {
294 return AppColor::from_any(&display_category);
295 }
296
297 let mapped_category = crate::i18n::get_color_category_for_display(&display_category);
298 AppColor::from_any(mapped_category)
299}
300
301pub fn create_output_widget<'a>(
303 messages: &'a [(&'a String, usize)],
304 available_height: u16,
305 config: &Config,
306) -> Paragraph<'a> {
307 let mut lines = Vec::new();
308 let max_lines = available_height as usize; if max_lines == 0 {
312 return Paragraph::new(vec![Line::from(vec![Span::raw("⚠️ NO SPACE")])]).block(
313 Block::default()
314 .borders(Borders::NONE)
315 .style(Style::default().bg(config.theme.output_bg.into())),
316 );
317 }
318
319 if messages.is_empty() {
320 let empty_lines = vec![Line::from(vec![Span::raw("")]); max_lines];
321 return Paragraph::new(empty_lines)
322 .block(
323 Block::default()
324 .borders(Borders::NONE)
325 .style(Style::default().bg(config.theme.output_bg.into())),
326 )
327 .wrap(Wrap { trim: true });
328 }
329
330 for (message_idx, (message, current_length)) in messages.iter().enumerate() {
332 let is_last_message = message_idx == messages.len() - 1;
333 let clean_message = clean_message_for_display(message);
334 let message_lines: Vec<&str> = clean_message.lines().collect();
335
336 if message_lines.is_empty() {
337 lines.push(Line::from(vec![Span::raw("")]));
338 } else {
339 for (line_idx, line_content) in message_lines.iter().enumerate() {
340 if lines.len() >= max_lines {
341 break; }
343
344 let is_last_line = line_idx == message_lines.len() - 1;
345
346 let visible_chars = if is_last_message && is_last_line {
347 let chars_before_this_line: usize = message_lines
348 .iter()
349 .take(line_idx)
350 .map(|l| l.graphemes(true).count() + 1)
351 .sum();
352
353 let available_for_this_line =
354 current_length.saturating_sub(chars_before_this_line);
355 available_for_this_line.min(line_content.graphemes(true).count())
356 } else {
357 line_content.graphemes(true).count()
358 };
359
360 let message_parts = parse_message_parts(line_content);
361 let mut spans = Vec::new();
362 let mut chars_used = 0;
363
364 for (part_text, is_marker) in message_parts {
365 let part_chars = part_text.graphemes(true).count();
366 let part_style = if is_marker {
367 Style::default().fg(get_marker_color(&part_text).into())
368 } else {
369 Style::default().fg(config.theme.output_text.into())
370 };
371
372 if chars_used >= visible_chars {
373 break;
374 }
375
376 let chars_needed = visible_chars - chars_used;
377
378 if chars_needed >= part_chars {
379 spans.push(Span::styled(part_text, part_style));
380 chars_used += part_chars;
381 } else {
382 let graphemes: Vec<&str> = part_text.graphemes(true).collect();
383 spans.push(Span::styled(
384 graphemes
385 .iter()
386 .take(chars_needed)
387 .copied()
388 .collect::<String>(),
389 part_style,
390 ));
391 break;
392 }
393 }
394
395 if spans.is_empty() {
396 spans.push(Span::raw(""));
397 }
398
399 lines.push(Line::from(spans));
400 }
401 }
402
403 if lines.len() >= max_lines {
404 break;
405 }
406 }
407
408 while lines.len() < max_lines {
410 lines.push(Line::from(vec![Span::raw("")]));
411 }
412
413 lines.truncate(max_lines);
415
416 Paragraph::new(lines)
417 .block(
418 Block::default()
419 .borders(Borders::NONE)
420 .style(Style::default().bg(config.theme.output_bg.into())),
421 )
422 .wrap(Wrap { trim: true })
423}