Skip to main content

scrin_widgets/
lib.rs

1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4use std::borrow::Cow;
5
6use ratatui::{
7    buffer::Buffer,
8    layout::Rect,
9    style::{Color, Modifier, Style},
10    widgets::{Block, Widget},
11};
12
13pub use ratatui;
14
15/// Common imports for apps that want the Scrin widget set plus Ratatui.
16pub mod prelude {
17    pub use crate::{
18        Aisling, AislingEffect, AislingExt, AislingPalette, GlyphRain, NebulaGauge, SignalPanel,
19        ratatui,
20    };
21    pub use ratatui::widgets::Widget;
22}
23
24/// The default color system used by Aisling effects and bundled widgets.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub struct AislingPalette {
27    pub low: Color,
28    pub mid: Color,
29    pub high: Color,
30    pub pulse: Color,
31    pub shadow: Color,
32}
33
34impl AislingPalette {
35    /// Cyan, violet, and amber tones intended for dark terminals.
36    #[must_use]
37    pub const fn dream() -> Self {
38        Self {
39            low: Color::Rgb(58, 192, 255),
40            mid: Color::Rgb(176, 92, 255),
41            high: Color::Rgb(255, 219, 125),
42            pulse: Color::Rgb(255, 118, 205),
43            shadow: Color::Rgb(17, 18, 35),
44        }
45    }
46
47    /// Green phosphor tones for surveillance or terminal-core screens.
48    #[must_use]
49    pub const fn phosphor() -> Self {
50        Self {
51            low: Color::Rgb(61, 255, 142),
52            mid: Color::Rgb(19, 189, 112),
53            high: Color::Rgb(210, 255, 181),
54            pulse: Color::Rgb(135, 255, 221),
55            shadow: Color::Rgb(7, 22, 16),
56        }
57    }
58
59    /// Hot magenta/orange tones for signal-heavy panels.
60    #[must_use]
61    pub const fn flare() -> Self {
62        Self {
63            low: Color::Rgb(255, 107, 107),
64            mid: Color::Rgb(255, 168, 76),
65            high: Color::Rgb(255, 236, 153),
66            pulse: Color::Rgb(255, 75, 145),
67            shadow: Color::Rgb(35, 14, 24),
68        }
69    }
70
71    fn lane(self, value: u64) -> Color {
72        match value % 4 {
73            0 => self.low,
74            1 => self.mid,
75            2 => self.high,
76            _ => self.pulse,
77        }
78    }
79}
80
81impl Default for AislingPalette {
82    fn default() -> Self {
83        Self::dream()
84    }
85}
86
87/// A composable post-render effect that can be applied to any Ratatui buffer area.
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub struct AislingEffect {
90    tick: u64,
91    intensity: u16,
92    palette: AislingPalette,
93    shimmer: bool,
94    scanlines: bool,
95    glow: bool,
96}
97
98impl AislingEffect {
99    /// Creates an animated effect for the given frame or time tick.
100    #[must_use]
101    pub fn new(tick: u64) -> Self {
102        Self {
103            tick,
104            ..Self::default()
105        }
106    }
107
108    /// Sets the frame or time tick used to animate the effect.
109    #[must_use]
110    pub fn tick(mut self, tick: u64) -> Self {
111        self.tick = tick;
112        self
113    }
114
115    /// Sets the palette used by the effect.
116    #[must_use]
117    pub fn palette(mut self, palette: AislingPalette) -> Self {
118        self.palette = palette;
119        self
120    }
121
122    /// Sets effect strength on a 0..=10 scale.
123    #[must_use]
124    pub fn intensity(mut self, intensity: u16) -> Self {
125        self.intensity = intensity.min(10);
126        self
127    }
128
129    /// Enables or disables moving foreground highlights.
130    #[must_use]
131    pub fn shimmer(mut self, enabled: bool) -> Self {
132        self.shimmer = enabled;
133        self
134    }
135
136    /// Enables or disables low-contrast background scanlines.
137    #[must_use]
138    pub fn scanlines(mut self, enabled: bool) -> Self {
139        self.scanlines = enabled;
140        self
141    }
142
143    /// Enables or disables border/edge pulses.
144    #[must_use]
145    pub fn glow(mut self, enabled: bool) -> Self {
146        self.glow = enabled;
147        self
148    }
149
150    /// Applies this effect directly to an already-rendered buffer area.
151    pub fn apply(self, area: Rect, buf: &mut Buffer) {
152        if area.is_empty() || self.intensity == 0 {
153            return;
154        }
155
156        let right = area.x.saturating_add(area.width);
157        let bottom = area.y.saturating_add(area.height);
158        let edge_phase = self.tick / 2;
159        let shimmer_gate = 11_u64.saturating_sub(u64::from(self.intensity.min(10)));
160
161        for y in area.y..bottom {
162            for x in area.x..right {
163                let cell = &mut buf[(x, y)];
164
165                if self.scanlines && (u64::from(y) + edge_phase).is_multiple_of(3) {
166                    cell.set_bg(self.palette.shadow);
167                }
168
169                if self.shimmer {
170                    let phase = u64::from(x) * 3 + u64::from(y) * 5 + self.tick;
171                    if phase % 11 >= shimmer_gate {
172                        cell.set_style(
173                            Style::default()
174                                .fg(self.palette.lane(phase))
175                                .add_modifier(Modifier::BOLD),
176                        );
177                    }
178                }
179
180                if self.glow
181                    && is_edge(area, x, y)
182                    && (u64::from(x) + u64::from(y) + edge_phase) % 5 == 0
183                {
184                    cell.set_style(
185                        Style::default()
186                            .fg(self.palette.pulse)
187                            .add_modifier(Modifier::BOLD),
188                    );
189                }
190            }
191        }
192    }
193}
194
195impl Default for AislingEffect {
196    fn default() -> Self {
197        Self {
198            tick: 0,
199            intensity: 5,
200            palette: AislingPalette::default(),
201            shimmer: true,
202            scanlines: true,
203            glow: true,
204        }
205    }
206}
207
208/// Wraps any Ratatui widget with an Aisling post-render effect.
209#[derive(Clone, Debug, Eq, PartialEq)]
210pub struct Aisling<W> {
211    inner: W,
212    effect: AislingEffect,
213}
214
215impl<W> Aisling<W> {
216    /// Creates an effect wrapper around any Ratatui widget.
217    #[must_use]
218    pub fn new(inner: W) -> Self {
219        Self {
220            inner,
221            effect: AislingEffect::default(),
222        }
223    }
224
225    /// Replaces the effect used by the wrapper.
226    #[must_use]
227    pub fn effect(mut self, effect: AislingEffect) -> Self {
228        self.effect = effect;
229        self
230    }
231
232    /// Sets the animation tick.
233    #[must_use]
234    pub fn tick(mut self, tick: u64) -> Self {
235        self.effect = self.effect.tick(tick);
236        self
237    }
238
239    /// Sets the palette.
240    #[must_use]
241    pub fn palette(mut self, palette: AislingPalette) -> Self {
242        self.effect = self.effect.palette(palette);
243        self
244    }
245
246    /// Sets effect strength on a 0..=10 scale.
247    #[must_use]
248    pub fn intensity(mut self, intensity: u16) -> Self {
249        self.effect = self.effect.intensity(intensity);
250        self
251    }
252}
253
254impl<W: Widget> Widget for Aisling<W> {
255    fn render(self, area: Rect, buf: &mut Buffer) {
256        self.inner.render(area, buf);
257        self.effect.apply(area, buf);
258    }
259}
260
261/// Extension trait for calling `.aisling()` on any Ratatui widget.
262pub trait AislingExt: Widget + Sized {
263    /// Decorates this widget with the default Aisling effect.
264    #[must_use]
265    fn aisling(self) -> Aisling<Self> {
266        Aisling::new(self)
267    }
268}
269
270impl<W: Widget> AislingExt for W {}
271
272/// A deterministic matrix/rain field for ambient Scrin backgrounds.
273#[derive(Clone, Debug, Eq, PartialEq)]
274pub struct GlyphRain<'a> {
275    tick: u64,
276    density: u16,
277    glyphs: Cow<'a, str>,
278    palette: AislingPalette,
279    block: Option<Block<'a>>,
280}
281
282impl<'a> GlyphRain<'a> {
283    /// Creates a glyph rain widget for the given animation tick.
284    #[must_use]
285    pub fn new(tick: u64) -> Self {
286        Self {
287            tick,
288            density: 34,
289            glyphs: Cow::Borrowed("01#$*+<>[]{}"),
290            palette: AislingPalette::phosphor(),
291            block: None,
292        }
293    }
294
295    /// Sets the animation tick.
296    #[must_use]
297    pub fn tick(mut self, tick: u64) -> Self {
298        self.tick = tick;
299        self
300    }
301
302    /// Sets density on a 0..=100 scale.
303    #[must_use]
304    pub fn density(mut self, density: u16) -> Self {
305        self.density = density.min(100);
306        self
307    }
308
309    /// Sets the glyph alphabet used by the rain field.
310    #[must_use]
311    pub fn glyphs(mut self, glyphs: impl Into<Cow<'a, str>>) -> Self {
312        self.glyphs = glyphs.into();
313        self
314    }
315
316    /// Sets the color palette.
317    #[must_use]
318    pub fn palette(mut self, palette: AislingPalette) -> Self {
319        self.palette = palette;
320        self
321    }
322
323    /// Adds a block around the field.
324    #[must_use]
325    pub fn block(mut self, block: Block<'a>) -> Self {
326        self.block = Some(block);
327        self
328    }
329}
330
331impl Widget for GlyphRain<'_> {
332    fn render(self, area: Rect, buf: &mut Buffer) {
333        let inner = self.block.as_ref().map_or(area, |block| block.inner(area));
334        if let Some(block) = self.block {
335            block.render(area, buf);
336        }
337        if inner.is_empty() || self.density == 0 {
338            return;
339        }
340
341        let glyphs: Vec<char> = self.glyphs.chars().collect();
342        if glyphs.is_empty() {
343            return;
344        }
345
346        let right = inner.x.saturating_add(inner.width);
347        let bottom = inner.y.saturating_add(inner.height);
348        for y in inner.y..bottom {
349            for x in inner.x..right {
350                let noise = field_noise(x, y, self.tick);
351                if noise % 100 >= u64::from(self.density) {
352                    continue;
353                }
354
355                let glyph = glyphs[(noise as usize + usize::from(y)) % glyphs.len()];
356                let mut encoded = [0; 4];
357                let symbol = glyph.encode_utf8(&mut encoded);
358                let head = (noise + self.tick) % 9 == 0;
359                let style = if head {
360                    Style::default()
361                        .fg(self.palette.high)
362                        .add_modifier(Modifier::BOLD)
363                } else {
364                    Style::default().fg(self.palette.lane(noise + self.tick))
365                };
366
367                let cell = &mut buf[(x, y)];
368                cell.set_symbol(symbol);
369                cell.set_style(style);
370            }
371        }
372    }
373}
374
375/// A compact progress gauge with a flowing nebula fill.
376#[derive(Clone, Debug, PartialEq)]
377pub struct NebulaGauge<'a> {
378    ratio: f64,
379    tick: u64,
380    label: Option<Cow<'a, str>>,
381    palette: AislingPalette,
382    block: Option<Block<'a>>,
383}
384
385impl<'a> NebulaGauge<'a> {
386    /// Creates a gauge with a ratio clamped to 0.0..=1.0.
387    #[must_use]
388    pub fn new(ratio: f64) -> Self {
389        Self {
390            ratio: ratio.clamp(0.0, 1.0),
391            tick: 0,
392            label: None,
393            palette: AislingPalette::dream(),
394            block: None,
395        }
396    }
397
398    /// Returns the clamped ratio.
399    #[must_use]
400    pub fn ratio(&self) -> f64 {
401        self.ratio
402    }
403
404    /// Sets the animation tick.
405    #[must_use]
406    pub fn tick(mut self, tick: u64) -> Self {
407        self.tick = tick;
408        self
409    }
410
411    /// Sets the centered label.
412    #[must_use]
413    pub fn label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
414        self.label = Some(label.into());
415        self
416    }
417
418    /// Sets the color palette.
419    #[must_use]
420    pub fn palette(mut self, palette: AislingPalette) -> Self {
421        self.palette = palette;
422        self
423    }
424
425    /// Adds a block around the gauge.
426    #[must_use]
427    pub fn block(mut self, block: Block<'a>) -> Self {
428        self.block = Some(block);
429        self
430    }
431}
432
433impl Widget for NebulaGauge<'_> {
434    fn render(self, area: Rect, buf: &mut Buffer) {
435        let inner = self.block.as_ref().map_or(area, |block| block.inner(area));
436        if let Some(block) = self.block {
437            block.render(area, buf);
438        }
439        if inner.is_empty() {
440            return;
441        }
442
443        let right = inner.x.saturating_add(inner.width);
444        let bottom = inner.y.saturating_add(inner.height);
445        let filled = (f64::from(inner.width) * self.ratio).round() as u16;
446
447        for y in inner.y..bottom {
448            for x in inner.x..right {
449                let offset = x.saturating_sub(inner.x);
450                let flow = u64::from(offset) + u64::from(y) * 2 + self.tick;
451                let cell = &mut buf[(x, y)];
452                if offset < filled {
453                    cell.set_symbol("█");
454                    cell.set_style(
455                        Style::default()
456                            .fg(self.palette.lane(flow))
457                            .bg(self.palette.shadow)
458                            .add_modifier(Modifier::BOLD),
459                    );
460                } else {
461                    cell.set_symbol("░");
462                    cell.set_style(Style::default().fg(self.palette.shadow));
463                }
464            }
465        }
466
467        if let Some(label) = self.label {
468            let row = inner.y + inner.height / 2;
469            let label_width = label.chars().count().min(usize::from(inner.width)) as u16;
470            let start = inner.x + inner.width.saturating_sub(label_width) / 2;
471            paint_text(
472                Rect::new(start, row, label_width, 1),
473                buf,
474                label.as_ref(),
475                Style::default()
476                    .fg(self.palette.high)
477                    .add_modifier(Modifier::BOLD),
478            );
479        }
480    }
481}
482
483/// A bordered status panel with animated signal bars.
484#[derive(Clone, Debug, Eq, PartialEq)]
485pub struct SignalPanel<'a> {
486    title: Cow<'a, str>,
487    lines: Vec<Cow<'a, str>>,
488    tick: u64,
489    palette: AislingPalette,
490}
491
492impl<'a> SignalPanel<'a> {
493    /// Creates a signal panel with a title.
494    #[must_use]
495    pub fn new(title: impl Into<Cow<'a, str>>) -> Self {
496        Self {
497            title: title.into(),
498            lines: Vec::new(),
499            tick: 0,
500            palette: AislingPalette::flare(),
501        }
502    }
503
504    /// Adds one body line.
505    #[must_use]
506    pub fn line(mut self, line: impl Into<Cow<'a, str>>) -> Self {
507        self.lines.push(line.into());
508        self
509    }
510
511    /// Replaces all body lines.
512    #[must_use]
513    pub fn lines<I, S>(mut self, lines: I) -> Self
514    where
515        I: IntoIterator<Item = S>,
516        S: Into<Cow<'a, str>>,
517    {
518        self.lines = lines.into_iter().map(Into::into).collect();
519        self
520    }
521
522    /// Sets the animation tick.
523    #[must_use]
524    pub fn tick(mut self, tick: u64) -> Self {
525        self.tick = tick;
526        self
527    }
528
529    /// Sets the color palette.
530    #[must_use]
531    pub fn palette(mut self, palette: AislingPalette) -> Self {
532        self.palette = palette;
533        self
534    }
535}
536
537impl Widget for SignalPanel<'_> {
538    fn render(self, area: Rect, buf: &mut Buffer) {
539        if area.is_empty() {
540            return;
541        }
542
543        let block = Block::bordered()
544            .title(self.title.as_ref())
545            .border_style(Style::default().fg(self.palette.mid));
546        let inner = block.inner(area);
547        block.render(area, buf);
548        if inner.is_empty() {
549            return;
550        }
551
552        let bars_width = inner.width.min(12);
553        let text_width = inner.width.saturating_sub(bars_width.saturating_add(1));
554        let max_lines = usize::from(inner.height);
555
556        for (index, line) in self.lines.into_iter().take(max_lines).enumerate() {
557            paint_text(
558                Rect::new(inner.x, inner.y + index as u16, text_width, 1),
559                buf,
560                line.as_ref(),
561                Style::default().fg(self.palette.high),
562            );
563        }
564
565        if bars_width == 0 {
566            return;
567        }
568
569        let bars_x = inner.x + inner.width.saturating_sub(bars_width);
570        for row in 0..inner.height {
571            for column in 0..bars_width {
572                let x = bars_x + column;
573                let y = inner.y + row;
574                let noise = field_noise(x, y, self.tick / 2);
575                let active = (noise + self.tick + u64::from(column)) % 7 <= 3;
576                let symbol = if active { "╱" } else { "·" };
577                let style = if active {
578                    Style::default()
579                        .fg(self.palette.lane(noise))
580                        .add_modifier(Modifier::BOLD)
581                } else {
582                    Style::default().fg(self.palette.shadow)
583                };
584                let cell = &mut buf[(x, y)];
585                cell.set_symbol(symbol);
586                cell.set_style(style);
587            }
588        }
589    }
590}
591
592fn is_edge(area: Rect, x: u16, y: u16) -> bool {
593    x == area.x
594        || y == area.y
595        || x + 1 == area.x.saturating_add(area.width)
596        || y + 1 == area.y.saturating_add(area.height)
597}
598
599fn field_noise(x: u16, y: u16, tick: u64) -> u64 {
600    let mut value = u64::from(x).wrapping_mul(0x9e37_79b9_7f4a_7c15)
601        ^ u64::from(y).wrapping_mul(0xbf58_476d_1ce4_e5b9)
602        ^ tick.wrapping_mul(0x94d0_49bb_1331_11eb);
603    value ^= value >> 30;
604    value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9);
605    value ^= value >> 27;
606    value = value.wrapping_mul(0x94d0_49bb_1331_11eb);
607    value ^ (value >> 31)
608}
609
610fn paint_text(area: Rect, buf: &mut Buffer, text: &str, style: Style) {
611    if area.is_empty() {
612        return;
613    }
614
615    let right = area.x.saturating_add(area.width);
616    for (offset, glyph) in text.chars().take(usize::from(area.width)).enumerate() {
617        let x = area.x + offset as u16;
618        if x >= right {
619            break;
620        }
621        let mut encoded = [0; 4];
622        let symbol = glyph.encode_utf8(&mut encoded);
623        let cell = &mut buf[(x, area.y)];
624        cell.set_symbol(symbol);
625        cell.set_style(style);
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    #[test]
634    fn gauge_ratio_is_clamped() {
635        assert_eq!(NebulaGauge::new(1.5).ratio(), 1.0);
636        assert_eq!(NebulaGauge::new(-1.0).ratio(), 0.0);
637    }
638
639    #[test]
640    fn effect_can_be_applied_to_a_buffer() {
641        let area = Rect::new(0, 0, 12, 4);
642        let mut buf = Buffer::empty(area);
643
644        AislingEffect::new(8).intensity(7).apply(area, &mut buf);
645    }
646}