返回 CodeWhale
hot_tail.rs
根目录 / crates / tui / src / tui / hot_tail.rs
1 //! Illuminated "hot tail" for active prose streaming.
2 //!
3 //! Renders the newest ~8–16 graphemes brighter (and optionally bold) while a
4 //! response is live. Settled text uses the normal palette. Boundaries are
5 //! tracked on grapheme clusters, never byte indices.
6 //!
7 //! Activate only while responding; resolve the entire block to settled styling
8 //! when the stream completes. Reduced motion freezes the breathing luminance
9 //! cycle but keeps the static hot highlight.
10
11 use ratatui::style::{Color, Modifier, Style};
12 use unicode_segmentation::UnicodeSegmentation;
13
14 use crate::tui::ocean;
15
16 /// Default hot-tail length in graphemes.
17 pub const HOT_TAIL_GRAPHEMES: usize = 12;
18
19 /// Split `text` into (settled, hot) grapheme spans for streaming render.
20 ///
21 /// Returns `(settled, hot)` where `hot` is the trailing up-to-`n` graphemes
22 /// when `active` is true; otherwise the whole string is settled.
23 #[must_use]
24 pub fn split_hot_tail(text: &str, active: bool, n: usize) -> (&str, &str) {
25 if !active || n == 0 || text.is_empty() {
26 return (text, "");
27 }
28 let graphemes: Vec<&str> = text.graphemes(true).collect();
29 if graphemes.len() <= n {
30 return ("", text);
31 }
32 let split_at = graphemes.len() - n;
33 let mut byte = 0usize;
34 for g in graphemes.iter().take(split_at) {
35 byte += g.len();
36 }
37 (&text[..byte], &text[byte..])
38 }
39
40 /// Breathing luminance factor on a slow ~1.5 s cycle. Fixed under reduced motion.
41 #[must_use]
42 pub fn breath_luminance(elapsed_ms: u128, reduced_motion: bool) -> f32 {
43 if reduced_motion {
44 return 1.12;
45 }
46 let period = 1_500u128;
47 let phase = (elapsed_ms % period) as f32 / period as f32;
48 let s = (phase * std::f32::consts::TAU).sin();
49 // 1.05 … 1.22
50 1.135 + s * 0.085
51 }
52
53 /// Style for the hot tail of an active streaming block.
54 #[must_use]
55 pub fn hot_tail_style(base_fg: Color, elapsed_ms: u128, reduced_motion: bool) -> Style {
56 let scale = breath_luminance(elapsed_ms, reduced_motion);
57 let fg = ocean::scale_color(base_fg, scale);
58 Style::default().fg(fg).add_modifier(Modifier::BOLD)
59 }
60
61 /// Style for settled prose (muted relative to hot).
62 #[must_use]
63 #[allow(dead_code)]
64 pub fn settled_style(base_fg: Color) -> Style {
65 Style::default().fg(base_fg)
66 }
67
68 #[cfg(test)]
69 mod tests {
70 use super::*;
71
72 #[test]
73 fn split_hot_tail_on_grapheme_boundary() {
74 let text = "hello 世界!";
75 let (settled, hot) = split_hot_tail(text, true, 3);
76 assert_eq!(hot.graphemes(true).count(), 3);
77 assert_eq!(format!("{settled}{hot}"), text);
78 }
79
80 #[test]
81 fn inactive_stream_has_no_hot_tail() {
82 let (settled, hot) = split_hot_tail("abcdef", false, 12);
83 assert_eq!(settled, "abcdef");
84 assert!(hot.is_empty());
85 }
86 }
87
87 lines RUST