rush_sync_server/output/
display.rs

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        // ✅ HIER EINFÜGEN - nach rebuild_line_cache():
288        if force_instant || !use_typewriter {
289            self.viewport.enable_auto_scroll_silent();
290        }
291
292        if self.viewport.is_auto_scroll_enabled() {
293            let content_height = self.line_cache.len();
294            let window_height = self.viewport.window_height();
295            if content_height > window_height {
296                self.viewport
297                    .set_scroll_offset_direct_silent(content_height - window_height);
298            }
299        }
300    }
301
302    pub fn update_typewriter(&mut self) {
303        self.persistent_cursor.update_blink();
304        if self.config.typewriter_delay.as_millis() == 0 {
305            return;
306        }
307
308        let mut needs_rebuild = false;
309        if let Some(last_message) = self.messages.last_mut() {
310            let total_length = last_message.content.graphemes(true).count();
311            if let Some(ref mut cursor) = last_message.typewriter_cursor {
312                cursor.update_blink();
313            }
314
315            if last_message.current_length < total_length {
316                let elapsed = last_message.timestamp.elapsed();
317                if elapsed >= self.config.typewriter_delay {
318                    let old_length = last_message.current_length;
319                    let chars_to_add = if self.config.typewriter_delay.as_millis() <= 5 {
320                        ((elapsed.as_millis() as f64
321                            / self.config.typewriter_delay.as_millis() as f64)
322                            .floor()
323                            .max(1.0)) as usize
324                    } else {
325                        1
326                    };
327
328                    let new_length = (last_message.current_length + chars_to_add).min(total_length);
329                    last_message.current_length = new_length;
330                    last_message.timestamp = Instant::now();
331
332                    let next_chars = last_message
333                        .content
334                        .chars()
335                        .skip(old_length)
336                        .take(chars_to_add)
337                        .collect::<String>();
338                    if next_chars.contains('\n') || (new_length - old_length) > 50 {
339                        needs_rebuild = true;
340                    }
341
342                    self.cache_dirty = true;
343                    if new_length == total_length {
344                        last_message.typewriter_cursor = None;
345                        needs_rebuild = true;
346                        self.viewport.enable_auto_scroll_silent();
347                        self.viewport.scroll_to_bottom();
348                    }
349                }
350            }
351        }
352        if needs_rebuild && self.cache_dirty {
353            self.rebuild_line_cache();
354        }
355    }
356
357    pub fn handle_scroll(&mut self, direction: ScrollDirection, amount: usize) {
358        match direction {
359            ScrollDirection::Up => self.viewport.scroll_up(amount.max(1)),
360            ScrollDirection::Down => self.viewport.scroll_down(amount.max(1)),
361            ScrollDirection::PageUp => self.viewport.page_up(),
362            ScrollDirection::PageDown => self.viewport.page_down(),
363            ScrollDirection::ToTop => self.viewport.scroll_to_top(),
364            ScrollDirection::ToBottom => self.viewport.scroll_to_bottom(),
365        }
366    }
367
368    pub fn handle_resize(&mut self, width: u16, height: u16) -> bool {
369        let changed = self.viewport.update_terminal_size(width, height);
370        if changed {
371            for message in &mut self.messages {
372                message.calculate_wrapped_line_count(&self.viewport);
373            }
374            self.cache_dirty = true;
375            self.viewport.force_auto_scroll();
376        }
377        changed
378    }
379
380    pub fn clear_messages(&mut self) {
381        self.messages.clear();
382        self.line_cache.clear();
383        self.cache_dirty = false;
384        self.viewport.update_content_height_silent(0);
385        self.viewport.force_auto_scroll();
386        self.persistent_cursor.show_cursor();
387    }
388
389    pub fn create_output_widget_for_rendering(&mut self) -> RenderData<'_> {
390        (
391            self.get_visible_messages(),
392            self.config.clone(),
393            self.viewport.output_area(),
394            &self.persistent_cursor,
395        )
396    }
397
398    pub fn update_config(&mut self, new_config: &Config) {
399        self.config = new_config.clone();
400        self.persistent_cursor = UiCursor::from_config(new_config, CursorKind::Output);
401        self.cache_dirty = true;
402        if self.messages.len() > self.config.max_messages {
403            let excess = self.messages.len() - self.config.max_messages;
404            self.messages.drain(0..excess);
405            self.cache_dirty = true;
406        }
407    }
408
409    // Getters
410    pub fn viewport(&self) -> &Viewport {
411        &self.viewport
412    }
413    pub fn viewport_mut(&mut self) -> &mut Viewport {
414        &mut self.viewport
415    }
416    pub fn get_messages_count(&self) -> usize {
417        self.messages.len()
418    }
419    pub fn get_line_count(&self) -> usize {
420        if self.cache_dirty {
421            self.messages.iter().map(|m| m.line_count).sum()
422        } else {
423            self.line_cache.len()
424        }
425    }
426    pub fn get_content_height(&self) -> usize {
427        self.viewport.content_height()
428    }
429    pub fn get_window_height(&self) -> usize {
430        self.viewport.window_height()
431    }
432
433    pub fn debug_scroll_status(&self) -> String {
434        format!(
435            "Scroll: offset={}, lines={}, window={}, auto={}, msgs={}, cache={}",
436            self.viewport.scroll_offset(),
437            self.viewport.content_height(),
438            self.viewport.window_height(),
439            self.viewport.is_auto_scroll_enabled(),
440            self.messages.len(),
441            self.line_cache.len()
442        )
443    }
444
445    pub fn handle_viewport_event(&mut self, event: ViewportEvent) -> bool {
446        let changed = self.viewport.handle_event(event);
447        if changed {
448            for message in &mut self.messages {
449                message.calculate_wrapped_line_count(&self.viewport);
450            }
451            self.cache_dirty = true;
452        }
453        changed
454    }
455
456    pub fn log(&mut self, level: &str, message: &str) {
457        self.add_message(format!("[{}] {}", level, message));
458    }
459
460    fn log_to_file(content: &str) {
461        if content.starts_with("__") || content.trim().is_empty() {
462            return;
463        }
464        if let Ok(exe_path) = std::env::current_exe() {
465            if let Some(base_dir) = exe_path.parent() {
466                let log_path = base_dir.join(".rss").join("rush.logs");
467                let _ = std::fs::create_dir_all(log_path.parent().unwrap());
468                let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
469                let log_line = format!("[{}] {}\n", timestamp, content);
470                let _ = std::fs::OpenOptions::new()
471                    .create(true)
472                    .append(true)
473                    .open(&log_path)
474                    .and_then(|mut file| {
475                        use std::io::Write;
476                        file.write_all(log_line.as_bytes())
477                    });
478            }
479        }
480    }
481
482    fn log_startup() {
483        if let Ok(exe_path) = std::env::current_exe() {
484            if let Some(base_dir) = exe_path.parent() {
485                let log_path = base_dir.join(".rss").join("rush.logs");
486                let _ = std::fs::create_dir_all(log_path.parent().unwrap());
487                let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
488                let version = crate::core::constants::VERSION;
489                let startup_line = format!(
490                    "[{}] === Rush Sync Server v{} Started ===\n",
491                    timestamp, version
492                );
493                let _ = std::fs::OpenOptions::new()
494                    .create(true)
495                    .append(true)
496                    .open(&log_path)
497                    .and_then(|mut file| {
498                        use std::io::Write;
499                        file.write_all(startup_line.as_bytes())
500                    });
501            }
502        }
503    }
504}
505
506// Utility functions
507fn clean_ansi_codes(message: &str) -> String {
508    String::from_utf8_lossy(&strip(message.as_bytes()).unwrap_or_default()).into_owned()
509}
510
511fn clean_message_for_display(message: &str) -> String {
512    clean_ansi_codes(message)
513        .replace("__CONFIRM_EXIT__", "")
514        .replace("__CLEAR__", "")
515        .trim()
516        .to_string()
517}
518
519fn parse_message_parts(message: &str) -> Vec<(String, bool)> {
520    let mut parts = Vec::new();
521    let mut chars = message.char_indices().peekable();
522    let mut start = 0;
523
524    while let Some((i, c)) = chars.peek().cloned() {
525        if c == '[' {
526            if start < i {
527                let text = &message[start..i];
528                if !text.trim().is_empty() {
529                    parts.push((text.to_owned(), false));
530                }
531            }
532            if let Some(end_idx) = message[i..].find(']') {
533                let end = i + end_idx + 1;
534                parts.push((message[i..end].to_owned(), true));
535                start = end;
536                while let Some(&(ci, _)) = chars.peek() {
537                    if ci < end {
538                        chars.next();
539                    } else {
540                        break;
541                    }
542                }
543            } else {
544                parts.push((message[i..].to_owned(), false));
545                break;
546            }
547        } else {
548            chars.next();
549        }
550    }
551
552    if start < message.len() {
553        let remaining = &message[start..];
554        if !remaining.trim().is_empty() {
555            parts.push((remaining.to_owned(), false));
556        }
557    }
558    if parts.is_empty() {
559        parts.push((message.to_owned(), false));
560    }
561    parts
562}
563
564fn get_marker_color(marker: &str) -> AppColor {
565    let display_text = marker.trim_start_matches('[').trim_end_matches(']');
566    AppColor::from_display_text(display_text) // ← NUR EINE ZEILE!
567}
568
569pub fn create_output_widget<'a>(
570    messages: &'a [(String, usize, bool, bool, bool)],
571    layout_area: crate::ui::viewport::LayoutArea,
572    config: &'a Config,
573    cursor_state: &'a UiCursor,
574) -> Paragraph<'a> {
575    let max_lines = layout_area.height as usize;
576    if max_lines == 0 || layout_area.width == 0 {
577        return Paragraph::new(vec![Line::from(vec![Span::raw(get_translation(
578            "screen.render.invalid_layout",
579            &[],
580        ))])])
581        .block(
582            Block::default()
583                .borders(Borders::NONE)
584                .style(Style::default().bg(config.theme.output_bg.into())),
585        );
586    }
587
588    let safe_max_lines = max_lines.min(1000);
589    let mut lines = Vec::new();
590
591    if messages.is_empty() {
592        return Paragraph::new(vec![Line::from(vec![Span::raw("")]); safe_max_lines])
593            .block(
594                Block::default()
595                    .borders(Borders::NONE)
596                    .style(Style::default().bg(config.theme.output_bg.into())),
597            )
598            .wrap(Wrap { trim: true });
599    }
600
601    for (
602        message_idx,
603        (message, current_length, is_typing, msg_cursor_visible, persistent_cursor_visible),
604    ) in messages.iter().enumerate()
605    {
606        let is_last_message = message_idx == messages.len() - 1;
607
608        if message.is_empty() {
609            if *persistent_cursor_visible {
610                lines.push(Line::from(vec![cursor_state.create_cursor_span(config)]));
611            } else {
612                lines.push(Line::from(vec![Span::raw("")]));
613            }
614            continue;
615        }
616
617        let clean_message = clean_message_for_display(message);
618        let message_lines: Vec<&str> = clean_message.lines().collect();
619
620        if message_lines.is_empty() {
621            lines.push(Line::from(vec![Span::raw("")]));
622        } else {
623            for (line_idx, line_content) in message_lines.iter().enumerate() {
624                if lines.len() >= safe_max_lines {
625                    break;
626                }
627
628                let is_last_line = line_idx == message_lines.len() - 1;
629                let visible_chars = if is_last_message && is_last_line {
630                    let chars_before_this_line: usize = message_lines
631                        .iter()
632                        .take(line_idx)
633                        .map(|l| l.graphemes(true).count() + 1)
634                        .sum();
635                    current_length
636                        .saturating_sub(chars_before_this_line)
637                        .min(line_content.graphemes(true).count())
638                } else {
639                    line_content.graphemes(true).count()
640                };
641
642                let message_parts = parse_message_parts(line_content);
643                let mut spans = Vec::new();
644                let mut chars_used = 0;
645
646                for (part_text, is_marker) in message_parts {
647                    let part_chars = part_text.graphemes(true).count();
648                    let part_style = if is_marker {
649                        Style::default().fg(get_marker_color(&part_text).into())
650                    } else {
651                        Style::default().fg(config.theme.output_text.into())
652                    };
653
654                    if chars_used >= visible_chars {
655                        break;
656                    }
657                    let chars_needed = visible_chars - chars_used;
658                    if chars_needed >= part_chars {
659                        spans.push(Span::styled(part_text, part_style));
660                        chars_used += part_chars;
661                    } else {
662                        let graphemes: Vec<&str> = part_text.graphemes(true).collect();
663                        spans.push(Span::styled(
664                            graphemes
665                                .iter()
666                                .take(chars_needed)
667                                .copied()
668                                .collect::<String>(),
669                            part_style,
670                        ));
671                        break;
672                    }
673                }
674
675                if is_last_message
676                    && is_last_line
677                    && ((*is_typing && *msg_cursor_visible)
678                        || (!*is_typing && *persistent_cursor_visible))
679                {
680                    spans.push(cursor_state.create_cursor_span(config));
681                }
682                if spans.is_empty() {
683                    spans.push(Span::raw(""));
684                }
685                lines.push(Line::from(spans));
686            }
687        }
688        if lines.len() >= safe_max_lines {
689            break;
690        }
691    }
692
693    while lines.len() < safe_max_lines {
694        lines.push(Line::from(vec![Span::raw("")]));
695    }
696    lines.truncate(safe_max_lines);
697    if lines.is_empty() {
698        lines.push(Line::from(vec![Span::raw(get_translation(
699            "screen.render.empty_buffer_error",
700            &[],
701        ))]));
702    }
703
704    Paragraph::new(lines)
705        .block(
706            Block::default()
707                .borders(Borders::NONE)
708                .style(Style::default().bg(config.theme.output_bg.into())),
709        )
710        .wrap(Wrap { trim: true })
711}