1use ratatui::{
28 buffer::Buffer,
29 layout::Rect,
30 style::{Color, Modifier, Style},
31 widgets::Widget,
32};
33use unicode_width::UnicodeWidthStr;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum ScrollDir {
38 #[default]
40 Left,
41 Right,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum MarqueeMode {
48 #[default]
50 Continuous,
51 Bounce,
53 Static,
55}
56
57#[derive(Debug, Clone, Default)]
59pub struct MarqueeState {
60 pub offset: usize,
62 pub direction: ScrollDir,
64 pub paused_ticks: usize,
66}
67
68impl MarqueeState {
69 pub fn new() -> Self {
71 Self::default()
72 }
73
74 pub fn reset(&mut self) {
76 self.offset = 0;
77 self.direction = ScrollDir::Left;
78 self.paused_ticks = 0;
79 }
80
81 pub fn tick(&mut self, text_width: usize, viewport_width: usize, style: &MarqueeStyle) {
88 if text_width <= viewport_width {
90 self.offset = 0;
91 return;
92 }
93
94 if self.paused_ticks > 0 {
96 self.paused_ticks -= 1;
97 return;
98 }
99
100 match style.mode {
101 MarqueeMode::Continuous => {
102 let total_width = text_width + style.separator.width();
106 self.offset = (self.offset + style.scroll_speed) % total_width;
107 }
108 MarqueeMode::Bounce => {
109 let max_offset = text_width.saturating_sub(viewport_width);
111
112 match self.direction {
113 ScrollDir::Left => {
114 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 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 }
137 }
138 }
139}
140
141#[derive(Debug, Clone)]
143pub struct MarqueeStyle {
144 pub text_style: Style,
146 pub scroll_speed: usize,
148 pub pause_at_edge: usize,
150 pub mode: MarqueeMode,
152 pub separator: &'static str,
154 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 pub fn new() -> Self {
188 Self::default()
189 }
190
191 pub fn text_style(mut self, style: Style) -> Self {
193 self.text_style = style;
194 self
195 }
196
197 pub fn scroll_speed(mut self, speed: usize) -> Self {
199 self.scroll_speed = speed.max(1);
200 self
201 }
202
203 pub fn pause_at_edge(mut self, ticks: usize) -> Self {
205 self.pause_at_edge = ticks;
206 self
207 }
208
209 pub fn mode(mut self, mode: MarqueeMode) -> Self {
211 self.mode = mode;
212 self
213 }
214
215 pub fn separator(mut self, sep: &'static str) -> Self {
217 self.separator = sep;
218 self
219 }
220
221 pub fn ellipsis(mut self, ellipsis: &'static str) -> Self {
223 self.ellipsis = ellipsis;
224 self
225 }
226
227 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 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 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
260pub struct MarqueeText<'a> {
267 text: &'a str,
269 style: MarqueeStyle,
271 state: &'a mut MarqueeState,
273}
274
275impl<'a> MarqueeText<'a> {
276 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 pub fn style(mut self, style: MarqueeStyle) -> Self {
287 self.style = style;
288 self
289 }
290
291 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
293 self.style(MarqueeStyle::from(theme))
294 }
295
296 pub fn text_style(mut self, style: Style) -> Self {
298 self.style.text_style = style;
299 self
300 }
301
302 pub fn mode(mut self, mode: MarqueeMode) -> Self {
304 self.style.mode = mode;
305 self
306 }
307
308 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 if skip_cols > 0 {
322 if ch_width <= skip_cols {
323 skip_cols -= ch_width;
324 continue;
325 } else {
326 result.push(' ');
328 current_col += 1;
329 skip_cols = 0;
330 continue;
331 }
332 }
333
334 if current_col + ch_width > width {
336 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 while current_col < width {
350 result.push(' ');
351 current_col += 1;
352 }
353
354 result
355 }
356
357 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_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 match self.style.mode {
375 MarqueeMode::Static => {
376 let ellipsis_width = self.style.ellipsis.width();
378 if viewport_width <= ellipsis_width {
379 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 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 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 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 let separator = self.style.separator;
403 let sep_width = separator.width();
404 let cycle_width = text_width + sep_width;
405
406 let effective_offset = self.state.offset % cycle_width;
408
409 let mut virtual_text = String::new();
411 let mut built_width = 0;
412 let mut pos = 0;
413
414 let mut skip = effective_offset;
416
417 while built_width < viewport_width {
419 let cycle_pos = pos % cycle_width;
421 let in_text = cycle_pos < text_width;
422
423 if in_text {
424 let text_offset = cycle_pos;
426 for ch in self.text.chars().skip_while(|_| {
427 let w = 0; 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 pos = (pos / cycle_width) * cycle_width + text_width;
453 } else {
454 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 pos = ((pos / cycle_width) + 1) * cycle_width;
483 }
484 }
485
486 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
504pub fn continuous_marquee<'a>(text: &'a str, state: &'a mut MarqueeState) -> MarqueeText<'a> {
506 MarqueeText::new(text, state).mode(MarqueeMode::Continuous)
507}
508
509pub 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 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 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 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); }
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 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 state.tick(15, 10, &style);
603 assert_eq!(state.offset, 5); assert_eq!(state.paused_ticks, 1);
605
606 state.tick(15, 10, &style);
607 assert_eq!(state.offset, 5); assert_eq!(state.paused_ticks, 0);
609
610 state.tick(15, 10, &style);
612 assert_eq!(state.offset, 0); }
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 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 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 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 marquee.render(Rect::new(0, 0, 10, 1), &mut buf);
704 }
705
706 #[test]
707 fn test_extract_visible_slice() {
708 let slice = MarqueeText::extract_visible_slice("Hello World", 0, 5);
710 assert_eq!(slice, "Hello");
711
712 let slice = MarqueeText::extract_visible_slice("Hello World", 6, 5);
714 assert_eq!(slice, "World");
715
716 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}