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