Skip to main content

ratatui_interact/components/
marquee.rs

1//! MarqueeText widget
2//!
3//! A scrolling text widget for displaying long text in constrained spaces.
4//!
5//! # Example
6//!
7//! ```rust
8//! use ratatui_interact::components::{MarqueeText, MarqueeState, MarqueeStyle, MarqueeMode};
9//! use ratatui::layout::Rect;
10//! use ratatui::buffer::Buffer;
11//! use ratatui::widgets::Widget;
12//!
13//! let mut state = MarqueeState::new();
14//! let style = MarqueeStyle::default();
15//!
16//! // Continuous scrolling
17//! let marquee = MarqueeText::new("/path/to/very/long/file/name.rs", &mut state)
18//!     .style(style);
19//!
20//! // Bounce mode
21//! let style = MarqueeStyle::default().mode(MarqueeMode::Bounce);
22//! let mut state = MarqueeState::new();
23//! let marquee = MarqueeText::new("Long status message here", &mut state)
24//!     .style(style);
25//! ```
26
27use ratatui::{
28    buffer::Buffer,
29    layout::Rect,
30    style::{Color, Modifier, Style},
31    widgets::Widget,
32};
33use unicode_width::UnicodeWidthStr;
34
35/// Scroll direction for bounce mode
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum ScrollDir {
38    /// Scrolling left (text moves left, revealing more on right)
39    #[default]
40    Left,
41    /// Scrolling right (text moves right, revealing more on left)
42    Right,
43}
44
45/// Scrolling mode for the marquee
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum MarqueeMode {
48    /// Text loops around continuously: "Hello World   Hello Wor..."
49    #[default]
50    Continuous,
51    /// Text bounces back and forth at edges
52    Bounce,
53    /// No scrolling, just truncate with ellipsis
54    Static,
55}
56
57/// State for tracking marquee animation
58#[derive(Debug, Clone, Default)]
59pub struct MarqueeState {
60    /// Current scroll offset (in display columns)
61    pub offset: usize,
62    /// Current direction (for bounce mode)
63    pub direction: ScrollDir,
64    /// Counter for edge pause
65    pub paused_ticks: usize,
66}
67
68impl MarqueeState {
69    /// Create a new marquee state
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Reset the state to initial position
75    pub fn reset(&mut self) {
76        self.offset = 0;
77        self.direction = ScrollDir::Left;
78        self.paused_ticks = 0;
79    }
80
81    /// Advance the animation by one tick
82    ///
83    /// # Arguments
84    /// * `text_width` - Display width of the text in columns
85    /// * `viewport_width` - Width of the visible area in columns
86    /// * `style` - The marquee style configuration
87    pub fn tick(&mut self, text_width: usize, viewport_width: usize, style: &MarqueeStyle) {
88        // Only scroll if text is wider than viewport
89        if text_width <= viewport_width {
90            self.offset = 0;
91            return;
92        }
93
94        // Handle edge pause
95        if self.paused_ticks > 0 {
96            self.paused_ticks -= 1;
97            return;
98        }
99
100        match style.mode {
101            MarqueeMode::Continuous => {
102                // For continuous mode, we create a virtual string of:
103                // "text + separator + text"
104                // and scroll through it, wrapping around
105                let total_width = text_width + style.separator.width();
106                self.offset = (self.offset + style.scroll_speed) % total_width;
107            }
108            MarqueeMode::Bounce => {
109                // Calculate the maximum offset (how far we can scroll)
110                let max_offset = text_width.saturating_sub(viewport_width);
111
112                match self.direction {
113                    ScrollDir::Left => {
114                        // Scrolling left (offset increases)
115                        self.offset = self.offset.saturating_add(style.scroll_speed);
116                        if self.offset >= max_offset {
117                            self.offset = max_offset;
118                            self.direction = ScrollDir::Right;
119                            self.paused_ticks = style.pause_at_edge;
120                        }
121                    }
122                    ScrollDir::Right => {
123                        // Scrolling right (offset decreases)
124                        if self.offset <= style.scroll_speed {
125                            self.offset = 0;
126                            self.direction = ScrollDir::Left;
127                            self.paused_ticks = style.pause_at_edge;
128                        } else {
129                            self.offset = self.offset.saturating_sub(style.scroll_speed);
130                        }
131                    }
132                }
133            }
134            MarqueeMode::Static => {
135                // No animation
136            }
137        }
138    }
139}
140
141/// Style configuration for marquee text
142#[derive(Debug, Clone)]
143pub struct MarqueeStyle {
144    /// Style for the text (color, modifiers)
145    pub text_style: Style,
146    /// Columns to scroll per tick (default: 1)
147    pub scroll_speed: usize,
148    /// Ticks to pause at each edge (default: 3)
149    pub pause_at_edge: usize,
150    /// Scrolling mode
151    pub mode: MarqueeMode,
152    /// Gap between repeated text for continuous mode (default: "   ")
153    pub separator: &'static str,
154    /// Ellipsis string for static mode truncation (default: "...")
155    pub ellipsis: &'static str,
156}
157
158impl Default for MarqueeStyle {
159    fn default() -> Self {
160        Self {
161            text_style: Style::default(),
162            scroll_speed: 1,
163            pause_at_edge: 3,
164            mode: MarqueeMode::default(),
165            separator: "   ",
166            ellipsis: "...",
167        }
168    }
169}
170
171impl From<&crate::theme::Theme> for MarqueeStyle {
172    fn from(theme: &crate::theme::Theme) -> Self {
173        let p = &theme.palette;
174        Self {
175            text_style: Style::default().fg(p.text),
176            scroll_speed: 1,
177            pause_at_edge: 3,
178            mode: MarqueeMode::default(),
179            separator: "   ",
180            ellipsis: "...",
181        }
182    }
183}
184
185impl MarqueeStyle {
186    /// Create a new style with default values
187    pub fn new() -> Self {
188        Self::default()
189    }
190
191    /// Set the text style
192    pub fn text_style(mut self, style: Style) -> Self {
193        self.text_style = style;
194        self
195    }
196
197    /// Set the scroll speed (columns per tick)
198    pub fn scroll_speed(mut self, speed: usize) -> Self {
199        self.scroll_speed = speed.max(1);
200        self
201    }
202
203    /// Set the pause duration at edges (in ticks)
204    pub fn pause_at_edge(mut self, ticks: usize) -> Self {
205        self.pause_at_edge = ticks;
206        self
207    }
208
209    /// Set the scrolling mode
210    pub fn mode(mut self, mode: MarqueeMode) -> Self {
211        self.mode = mode;
212        self
213    }
214
215    /// Set the separator for continuous mode
216    pub fn separator(mut self, sep: &'static str) -> Self {
217        self.separator = sep;
218        self
219    }
220
221    /// Set the ellipsis for static mode
222    pub fn ellipsis(mut self, ellipsis: &'static str) -> Self {
223        self.ellipsis = ellipsis;
224        self
225    }
226
227    /// Create a style for file paths (cyan text, bounce mode)
228    pub fn file_path() -> Self {
229        Self {
230            text_style: Style::default().fg(Color::Cyan),
231            mode: MarqueeMode::Bounce,
232            pause_at_edge: 5,
233            ..Default::default()
234        }
235    }
236
237    /// Create a style for status messages (yellow text, continuous)
238    pub fn status() -> Self {
239        Self {
240            text_style: Style::default()
241                .fg(Color::Yellow)
242                .add_modifier(Modifier::BOLD),
243            mode: MarqueeMode::Continuous,
244            scroll_speed: 1,
245            ..Default::default()
246        }
247    }
248
249    /// Create a style for titles (bold text, bounce mode)
250    pub fn title() -> Self {
251        Self {
252            text_style: Style::default().add_modifier(Modifier::BOLD),
253            mode: MarqueeMode::Bounce,
254            pause_at_edge: 10,
255            ..Default::default()
256        }
257    }
258}
259
260/// A scrolling text widget for displaying long text in limited space.
261///
262/// The marquee can operate in three modes:
263/// - `Continuous`: Text loops around with a separator
264/// - `Bounce`: Text scrolls back and forth
265/// - `Static`: Text is truncated with ellipsis
266pub struct MarqueeText<'a> {
267    /// The text to display
268    text: &'a str,
269    /// Style configuration
270    style: MarqueeStyle,
271    /// Mutable state for animation
272    state: &'a mut MarqueeState,
273}
274
275impl<'a> MarqueeText<'a> {
276    /// Create a new marquee text widget
277    pub fn new(text: &'a str, state: &'a mut MarqueeState) -> Self {
278        Self {
279            text,
280            style: MarqueeStyle::default(),
281            state,
282        }
283    }
284
285    /// Set the style
286    pub fn style(mut self, style: MarqueeStyle) -> Self {
287        self.style = style;
288        self
289    }
290
291    /// Apply a theme to derive the style
292    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
293        self.style(MarqueeStyle::from(theme))
294    }
295
296    /// Set the text style directly (shorthand)
297    pub fn text_style(mut self, style: Style) -> Self {
298        self.style.text_style = style;
299        self
300    }
301
302    /// Set the mode directly (shorthand)
303    pub fn mode(mut self, mode: MarqueeMode) -> Self {
304        self.style.mode = mode;
305        self
306    }
307
308    /// Extract a visible slice from the text based on offset and width
309    ///
310    /// Returns a string that fits within `width` display columns,
311    /// starting from `offset` display columns into the text.
312    fn extract_visible_slice(text: &str, offset: usize, width: usize) -> String {
313        let mut result = String::new();
314        let mut current_col = 0;
315        let mut skip_cols = offset;
316
317        for ch in text.chars() {
318            let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
319
320            // Skip characters until we reach the offset
321            if skip_cols > 0 {
322                if ch_width <= skip_cols {
323                    skip_cols -= ch_width;
324                    continue;
325                } else {
326                    // Character spans the boundary, add padding
327                    result.push(' ');
328                    current_col += 1;
329                    skip_cols = 0;
330                    continue;
331                }
332            }
333
334            // Check if this character fits
335            if current_col + ch_width > width {
336                // If it's a wide character that doesn't fit, add padding
337                if ch_width > 1 && current_col < width {
338                    result.push(' ');
339                    current_col += 1;
340                }
341                break;
342            }
343
344            result.push(ch);
345            current_col += ch_width;
346        }
347
348        // Pad to full width if needed
349        while current_col < width {
350            result.push(' ');
351            current_col += 1;
352        }
353
354        result
355    }
356
357    /// Render the marquee into the buffer
358    fn render_internal(self, area: Rect, buf: &mut Buffer) {
359        if area.width == 0 || area.height == 0 {
360            return;
361        }
362
363        let viewport_width = area.width as usize;
364        let text_width = self.text.width();
365
366        // If text fits, just render it (left-aligned)
367        if text_width <= viewport_width {
368            let padded = format!("{:<width$}", self.text, width = viewport_width);
369            buf.set_string(area.x, area.y, &padded, self.style.text_style);
370            return;
371        }
372
373        // Handle scrolling modes
374        match self.style.mode {
375            MarqueeMode::Static => {
376                // Truncate with ellipsis
377                let ellipsis_width = self.style.ellipsis.width();
378                if viewport_width <= ellipsis_width {
379                    // Not enough room for ellipsis, just truncate
380                    let visible = Self::extract_visible_slice(self.text, 0, viewport_width);
381                    buf.set_string(area.x, area.y, &visible, self.style.text_style);
382                } else {
383                    // Show truncated text with ellipsis
384                    let text_space = viewport_width - ellipsis_width;
385                    let visible = Self::extract_visible_slice(self.text, 0, text_space);
386                    let display = format!("{}{}", visible.trim_end(), self.style.ellipsis);
387                    // Pad to full width
388                    let padded = format!("{:<width$}", display, width = viewport_width);
389                    buf.set_string(area.x, area.y, &padded, self.style.text_style);
390                }
391            }
392            MarqueeMode::Bounce => {
393                // Simple offset-based slicing
394                let visible =
395                    Self::extract_visible_slice(self.text, self.state.offset, viewport_width);
396                buf.set_string(area.x, area.y, &visible, self.style.text_style);
397            }
398            MarqueeMode::Continuous => {
399                // Create virtual looped string and extract visible portion
400                // Virtual string: "text + separator + text + separator + ..."
401                // We need at least viewport_width characters from offset
402                let separator = self.style.separator;
403                let sep_width = separator.width();
404                let cycle_width = text_width + sep_width;
405
406                // Calculate where we are in the cycle
407                let effective_offset = self.state.offset % cycle_width;
408
409                // Build enough of the virtual string to fill the viewport
410                let mut virtual_text = String::new();
411                let mut built_width = 0;
412                let mut pos = 0;
413
414                // Skip to effective_offset
415                let mut skip = effective_offset;
416
417                // We'll iterate through cycles until we have enough
418                while built_width < viewport_width {
419                    // Determine if we're in text or separator portion
420                    let cycle_pos = pos % cycle_width;
421                    let in_text = cycle_pos < text_width;
422
423                    if in_text {
424                        // Extract from text
425                        let text_offset = cycle_pos;
426                        for ch in self.text.chars().skip_while(|_| {
427                            let w = 0; // placeholder
428                            w < text_offset
429                        }) {
430                            let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
431                            if skip > 0 {
432                                if ch_width <= skip {
433                                    skip -= ch_width;
434                                    pos += ch_width;
435                                    continue;
436                                } else {
437                                    skip = 0;
438                                    pos += ch_width;
439                                    virtual_text.push(' ');
440                                    built_width += 1;
441                                    continue;
442                                }
443                            }
444                            if built_width + ch_width > viewport_width {
445                                break;
446                            }
447                            virtual_text.push(ch);
448                            built_width += ch_width;
449                            pos += ch_width;
450                        }
451                        // Move to separator
452                        pos = (pos / cycle_width) * cycle_width + text_width;
453                    } else {
454                        // Extract from separator
455                        let sep_offset = cycle_pos - text_width;
456                        for (i, ch) in separator.chars().enumerate() {
457                            if i < sep_offset {
458                                continue;
459                            }
460                            let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
461                            if skip > 0 {
462                                if ch_width <= skip {
463                                    skip -= ch_width;
464                                    pos += ch_width;
465                                    continue;
466                                } else {
467                                    skip = 0;
468                                    pos += ch_width;
469                                    virtual_text.push(' ');
470                                    built_width += 1;
471                                    continue;
472                                }
473                            }
474                            if built_width + ch_width > viewport_width {
475                                break;
476                            }
477                            virtual_text.push(ch);
478                            built_width += ch_width;
479                            pos += ch_width;
480                        }
481                        // Move to next text
482                        pos = ((pos / cycle_width) + 1) * cycle_width;
483                    }
484                }
485
486                // Pad if needed
487                while built_width < viewport_width {
488                    virtual_text.push(' ');
489                    built_width += 1;
490                }
491
492                buf.set_string(area.x, area.y, &virtual_text, self.style.text_style);
493            }
494        }
495    }
496}
497
498impl Widget for MarqueeText<'_> {
499    fn render(self, area: Rect, buf: &mut Buffer) {
500        self.render_internal(area, buf);
501    }
502}
503
504/// Helper function to create a simple continuous marquee
505pub fn continuous_marquee<'a>(text: &'a str, state: &'a mut MarqueeState) -> MarqueeText<'a> {
506    MarqueeText::new(text, state).mode(MarqueeMode::Continuous)
507}
508
509/// Helper function to create a simple bounce marquee
510pub fn bounce_marquee<'a>(text: &'a str, state: &'a mut MarqueeState) -> MarqueeText<'a> {
511    MarqueeText::new(text, state).mode(MarqueeMode::Bounce)
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn test_marquee_state_new() {
520        let state = MarqueeState::new();
521        assert_eq!(state.offset, 0);
522        assert_eq!(state.direction, ScrollDir::Left);
523        assert_eq!(state.paused_ticks, 0);
524    }
525
526    #[test]
527    fn test_marquee_state_reset() {
528        let mut state = MarqueeState::new();
529        state.offset = 10;
530        state.direction = ScrollDir::Right;
531        state.paused_ticks = 5;
532
533        state.reset();
534
535        assert_eq!(state.offset, 0);
536        assert_eq!(state.direction, ScrollDir::Left);
537        assert_eq!(state.paused_ticks, 0);
538    }
539
540    #[test]
541    fn test_marquee_state_tick_short_text() {
542        let mut state = MarqueeState::new();
543        let style = MarqueeStyle::default();
544
545        // Text width (5) <= viewport width (10), should not scroll
546        state.tick(5, 10, &style);
547        assert_eq!(state.offset, 0);
548    }
549
550    #[test]
551    fn test_marquee_state_tick_continuous() {
552        let mut state = MarqueeState::new();
553        let style = MarqueeStyle::default()
554            .mode(MarqueeMode::Continuous)
555            .scroll_speed(1);
556
557        // Text width 20, viewport 10
558        state.tick(20, 10, &style);
559        assert_eq!(state.offset, 1);
560
561        state.tick(20, 10, &style);
562        assert_eq!(state.offset, 2);
563    }
564
565    #[test]
566    fn test_marquee_state_tick_bounce() {
567        let mut state = MarqueeState::new();
568        let style = MarqueeStyle::default()
569            .mode(MarqueeMode::Bounce)
570            .scroll_speed(5)
571            .pause_at_edge(0);
572
573        // Text width 20, viewport 10, max_offset = 10
574        // Should bounce at offset 10
575
576        // First few ticks going left
577        state.tick(20, 10, &style);
578        assert_eq!(state.offset, 5);
579        assert_eq!(state.direction, ScrollDir::Left);
580
581        state.tick(20, 10, &style);
582        assert_eq!(state.offset, 10);
583        assert_eq!(state.direction, ScrollDir::Right); // Should have reversed
584    }
585
586    #[test]
587    fn test_marquee_state_pause() {
588        let mut state = MarqueeState::new();
589        let style = MarqueeStyle::default()
590            .mode(MarqueeMode::Bounce)
591            .scroll_speed(10)
592            .pause_at_edge(2);
593
594        // Text width 15, viewport 10, max_offset = 5
595        // First tick should reach the edge
596        state.tick(15, 10, &style);
597        assert_eq!(state.offset, 5);
598        assert_eq!(state.paused_ticks, 2);
599        assert_eq!(state.direction, ScrollDir::Right);
600
601        // Next ticks should decrement pause
602        state.tick(15, 10, &style);
603        assert_eq!(state.offset, 5); // No movement
604        assert_eq!(state.paused_ticks, 1);
605
606        state.tick(15, 10, &style);
607        assert_eq!(state.offset, 5); // No movement
608        assert_eq!(state.paused_ticks, 0);
609
610        // Now should move again
611        state.tick(15, 10, &style);
612        assert_eq!(state.offset, 0); // Moved back (saturating)
613    }
614
615    #[test]
616    fn test_marquee_style_default() {
617        let style = MarqueeStyle::default();
618        assert_eq!(style.scroll_speed, 1);
619        assert_eq!(style.pause_at_edge, 3);
620        assert_eq!(style.mode, MarqueeMode::Continuous);
621        assert_eq!(style.separator, "   ");
622        assert_eq!(style.ellipsis, "...");
623    }
624
625    #[test]
626    fn test_marquee_style_builder() {
627        let style = MarqueeStyle::new()
628            .scroll_speed(2)
629            .pause_at_edge(5)
630            .mode(MarqueeMode::Bounce)
631            .separator(" | ")
632            .ellipsis("…");
633
634        assert_eq!(style.scroll_speed, 2);
635        assert_eq!(style.pause_at_edge, 5);
636        assert_eq!(style.mode, MarqueeMode::Bounce);
637        assert_eq!(style.separator, " | ");
638        assert_eq!(style.ellipsis, "…");
639    }
640
641    #[test]
642    fn test_marquee_render_fits() {
643        let mut state = MarqueeState::new();
644        let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
645        let marquee = MarqueeText::new("Hello", &mut state);
646
647        marquee.render(Rect::new(0, 0, 20, 1), &mut buf);
648
649        // Text should be left-aligned and padded
650        let content: String = buf
651            .content
652            .iter()
653            .map(|c| c.symbol().chars().next().unwrap_or(' '))
654            .collect();
655        assert!(content.starts_with("Hello"));
656    }
657
658    #[test]
659    fn test_marquee_render_scroll() {
660        let mut state = MarqueeState::new();
661        state.offset = 5;
662
663        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
664        let style = MarqueeStyle::default().mode(MarqueeMode::Bounce);
665        let marquee = MarqueeText::new("Hello World This Is Long", &mut state).style(style);
666
667        marquee.render(Rect::new(0, 0, 10, 1), &mut buf);
668
669        // Should show text from offset 5
670        let content: String = buf
671            .content
672            .iter()
673            .map(|c| c.symbol().chars().next().unwrap_or(' '))
674            .collect();
675        assert!(content.starts_with(" World T") || content.starts_with("World Th"));
676    }
677
678    #[test]
679    fn test_marquee_render_static() {
680        let mut state = MarqueeState::new();
681        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
682        let style = MarqueeStyle::default().mode(MarqueeMode::Static);
683        let marquee = MarqueeText::new("This is a very long text", &mut state).style(style);
684
685        marquee.render(Rect::new(0, 0, 10, 1), &mut buf);
686
687        // Should be truncated with ellipsis
688        let content: String = buf
689            .content
690            .iter()
691            .map(|c| c.symbol().chars().next().unwrap_or(' '))
692            .collect();
693        assert!(content.contains("..."));
694    }
695
696    #[test]
697    fn test_marquee_render_unicode() {
698        let mut state = MarqueeState::new();
699        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
700        let marquee = MarqueeText::new("日本語テスト", &mut state);
701
702        // Just verify it doesn't panic with wide characters
703        marquee.render(Rect::new(0, 0, 10, 1), &mut buf);
704    }
705
706    #[test]
707    fn test_extract_visible_slice() {
708        // Basic ASCII
709        let slice = MarqueeText::extract_visible_slice("Hello World", 0, 5);
710        assert_eq!(slice, "Hello");
711
712        // With offset
713        let slice = MarqueeText::extract_visible_slice("Hello World", 6, 5);
714        assert_eq!(slice, "World");
715
716        // Padding when shorter
717        let slice = MarqueeText::extract_visible_slice("Hi", 0, 5);
718        assert_eq!(slice, "Hi   ");
719    }
720
721    #[test]
722    fn test_helper_functions() {
723        let mut state1 = MarqueeState::new();
724        let m1 = continuous_marquee("test", &mut state1);
725        assert_eq!(m1.style.mode, MarqueeMode::Continuous);
726
727        let mut state2 = MarqueeState::new();
728        let m2 = bounce_marquee("test", &mut state2);
729        assert_eq!(m2.style.mode, MarqueeMode::Bounce);
730    }
731}