| 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 |