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, FlickerPanel, GlyphRain, NebulaGauge,
22        PulseRing, SignalPanel, WaveType, Waveform, 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
623/// A text panel with per-character flicker and glitch effects.
624#[derive(Clone, Debug)]
625pub struct FlickerPanel<'a> {
626    text: Cow<'a, str>,
627    tick: u64,
628    intensity: u16,
629    palette: AislingPalette,
630    block: Option<Block<'a>>,
631}
632
633impl PartialEq for FlickerPanel<'_> {
634    fn eq(&self, other: &Self) -> bool {
635        self.text == other.text
636            && self.tick == other.tick
637            && self.intensity == other.intensity
638            && self.palette == other.palette
639            && option_block_eq(self.block.as_ref(), other.block.as_ref())
640    }
641}
642
643impl Eq for FlickerPanel<'_> {}
644
645impl<'a> FlickerPanel<'a> {
646    /// Creates a flicker panel with the given text.
647    #[must_use]
648    pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
649        Self {
650            text: text.into(),
651            tick: 0,
652            intensity: 5,
653            palette: AislingPalette::dream(),
654            block: None,
655        }
656    }
657
658    /// Sets the animation tick.
659    #[must_use]
660    pub fn tick(mut self, tick: u64) -> Self {
661        self.tick = tick;
662        self
663    }
664
665    /// Sets glitch intensity on a 0..=10 scale (0 = no glitch).
666    #[must_use]
667    pub fn intensity(mut self, intensity: u16) -> Self {
668        self.intensity = intensity.min(10);
669        self
670    }
671
672    /// Sets the color palette.
673    #[must_use]
674    pub fn palette(mut self, palette: AislingPalette) -> Self {
675        self.palette = palette;
676        self
677    }
678
679    /// Adds a block around the panel.
680    #[must_use]
681    pub fn block(mut self, block: Block<'a>) -> Self {
682        self.block = Some(block);
683        self
684    }
685}
686
687impl Widget for FlickerPanel<'_> {
688    fn render(&self, buf: &mut Buffer, area: Rect) {
689        let inner = self
690            .block
691            .as_ref()
692            .map_or(area, |block| block_content_area(block, area));
693        if let Some(block) = &self.block {
694            block.render(buf, area);
695        }
696        if is_empty(inner) || self.intensity == 0 {
697            return;
698        }
699
700        let glitch_chars: Vec<char> = "░▒▓█▀▄▌▐│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬".chars().collect();
701        let text_chars: Vec<char> = self.text.chars().collect();
702        if text_chars.is_empty() {
703            return;
704        }
705
706        let right = inner.x.saturating_add(inner.width);
707        let bottom = inner.y.saturating_add(inner.height);
708        for y in inner.y..bottom {
709            for x in inner.x..right {
710                let col = usize::from(x.saturating_sub(inner.x));
711                let noise = field_noise(x, y, self.tick);
712                let glitch_gate = 11_u64.saturating_sub(u64::from(self.intensity));
713
714                let (ch, style) = if noise % 11 >= glitch_gate {
715                    let g = glitch_chars[(noise as usize) % glitch_chars.len()];
716                    (g, Style::default().fg(self.palette.pulse).add_modifier(Modifier::BOLD))
717                } else if col < text_chars.len() {
718                    let c = text_chars[col];
719                    let flicker = (noise + self.tick) % 9 == 0;
720                    let style = if flicker {
721                        Style::default()
722                            .fg(self.palette.high)
723                            .add_modifier(Modifier::BOLD)
724                    } else {
725                        Style::default().fg(self.palette.mid)
726                    };
727                    (c, style)
728                } else {
729                    (' ', Style::default())
730                };
731                set_styled_char(buf, x, y, ch, style);
732            }
733        }
734    }
735}
736
737/// An animated oscilloscope / waveform display.
738#[derive(Clone, Debug)]
739pub struct Waveform<'a> {
740    tick: u64,
741    frequency: f64,
742    amplitude: f64,
743    wave_type: WaveType,
744    palette: AislingPalette,
745    block: Option<Block<'a>>,
746}
747
748#[derive(Clone, Copy, Debug, Eq, PartialEq)]
749pub enum WaveType {
750    Sine,
751    Square,
752    Sawtooth,
753    Triangle,
754}
755
756impl PartialEq for Waveform<'_> {
757    fn eq(&self, other: &Self) -> bool {
758        self.tick == other.tick
759            && self.frequency == other.frequency
760            && self.amplitude == other.amplitude
761            && self.wave_type == other.wave_type
762            && self.palette == other.palette
763            && option_block_eq(self.block.as_ref(), other.block.as_ref())
764    }
765}
766
767impl Eq for Waveform<'_> {}
768
769impl<'a> Waveform<'a> {
770    /// Creates a waveform with the given frequency and amplitude.
771    #[must_use]
772    pub fn new(frequency: f64, amplitude: f64) -> Self {
773        Self {
774            tick: 0,
775            frequency,
776            amplitude: amplitude.clamp(0.0, 1.0),
777            wave_type: WaveType::Sine,
778            palette: AislingPalette::phosphor(),
779            block: None,
780        }
781    }
782
783    /// Sets the animation tick.
784    #[must_use]
785    pub fn tick(mut self, tick: u64) -> Self {
786        self.tick = tick;
787        self
788    }
789
790    /// Sets the wave type.
791    #[must_use]
792    pub fn wave_type(mut self, wave_type: WaveType) -> Self {
793        self.wave_type = wave_type;
794        self
795    }
796
797    /// Sets the color palette.
798    #[must_use]
799    pub fn palette(mut self, palette: AislingPalette) -> Self {
800        self.palette = palette;
801        self
802    }
803
804    /// Adds a block around the waveform.
805    #[must_use]
806    pub fn block(mut self, block: Block<'a>) -> Self {
807        self.block = Some(block);
808        self
809    }
810
811    fn sample(&self, phase: f64) -> f64 {
812        let t = phase.fract();
813        match self.wave_type {
814            WaveType::Sine => (std::f64::consts::TAU * t).sin(),
815            WaveType::Square => {
816                if t < 0.5 {
817                    1.0
818                } else {
819                    -1.0
820                }
821            }
822            WaveType::Sawtooth => 2.0 * t - 1.0,
823            WaveType::Triangle => {
824                if t < 0.5 {
825                    4.0 * t - 1.0
826                } else {
827                    3.0 - 4.0 * t
828                }
829            }
830        }
831    }
832}
833
834impl Widget for Waveform<'_> {
835    fn render(&self, buf: &mut Buffer, area: Rect) {
836        let inner = self
837            .block
838            .as_ref()
839            .map_or(area, |block| block_content_area(block, area));
840        if let Some(block) = &self.block {
841            block.render(buf, area);
842        }
843        if is_empty(inner) || inner.height < 3 {
844            return;
845        }
846
847        let mid_y = inner.y + inner.height / 2;
848        let half = (inner.height / 2) as f64;
849
850        for col in 0..inner.width {
851            let phase = f64::from(col) / f64::from(inner.width) * self.frequency
852                + f64::from(self.tick as u32) * 0.05;
853            let sample = self.sample(phase);
854            let offset = (sample * self.amplitude * half).round() as i16;
855
856            let y = mid_y as i16 + offset;
857            if y >= inner.y as i16 && y < (inner.y + inner.height) as i16 {
858                let noise = field_noise(inner.x + col, y as u16, self.tick);
859                set_styled_char(
860                    buf,
861                    inner.x + col,
862                    y as u16,
863                    '█',
864                    Style::default()
865                        .fg(self.palette.lane(noise))
866                        .add_modifier(Modifier::BOLD),
867                );
868            }
869        }
870    }
871}
872
873/// An animated expanding concentric ring effect.
874#[derive(Clone, Debug, Eq, PartialEq)]
875pub struct PulseRing {
876    tick: u64,
877    rings: u16,
878    palette: AislingPalette,
879}
880
881impl PulseRing {
882    /// Creates a pulse ring with the given number of concentric rings.
883    #[must_use]
884    pub fn new(rings: u16) -> Self {
885        Self {
886            tick: 0,
887            rings: rings.max(1),
888            palette: AislingPalette::dream(),
889        }
890    }
891
892    /// Sets the animation tick.
893    #[must_use]
894    pub fn tick(mut self, tick: u64) -> Self {
895        self.tick = tick;
896        self
897    }
898
899    /// Sets the color palette.
900    #[must_use]
901    pub fn palette(mut self, palette: AislingPalette) -> Self {
902        self.palette = palette;
903        self
904    }
905}
906
907impl Widget for PulseRing {
908    fn render(&self, buf: &mut Buffer, area: Rect) {
909        if is_empty(area) || self.rings == 0 {
910            return;
911        }
912
913        let cx = area.x + area.width / 2;
914        let cy = area.y + area.height / 2;
915        let max_radius = (area.width.min(area.height) / 2) as f64;
916        if max_radius < 1.0 {
917            return;
918        }
919
920        let right = area.x.saturating_add(area.width);
921        let bottom = area.y.saturating_add(area.height);
922
923        for y in area.y..bottom {
924            for x in area.x..right {
925                let dx = x as f64 - cx as f64;
926                let dy = y as f64 - cy as f64;
927                let dist = (dx * dx + dy * dy).sqrt();
928
929                for ring in 0..self.rings {
930                    let ring_phase = (self.tick as f64 * 0.1 + ring as f64 * 3.0) % max_radius;
931                    let diff = (dist - ring_phase).abs();
932                    if diff < 1.5 {
933                        let noise = field_noise(x, y, self.tick + ring as u64);
934                        let style = if diff < 0.8 {
935                            Style::default()
936                                .fg(self.palette.high)
937                                .add_modifier(Modifier::BOLD)
938                        } else {
939                            Style::default().fg(self.palette.lane(noise))
940                        };
941                        set_styled_char(buf, x, y, '○', style);
942                        break;
943                    }
944                }
945            }
946        }
947    }
948}
949
950fn is_empty(area: Rect) -> bool {
951    area.width == 0 || area.height == 0
952}
953
954fn block_content_area(block: &Block<'_>, area: Rect) -> Rect {
955    match block.borders {
956        BorderStyle::None => area,
957        _ => Rect::new(
958            area.x.saturating_add(1),
959            area.y.saturating_add(1),
960            area.width.saturating_sub(2),
961            area.height.saturating_sub(2),
962        ),
963    }
964}
965
966fn option_block_eq(left: Option<&Block<'_>>, right: Option<&Block<'_>>) -> bool {
967    match (left, right) {
968        (Some(left), Some(right)) => block_eq(left, right),
969        (None, None) => true,
970        _ => false,
971    }
972}
973
974fn block_eq(left: &Block<'_>, right: &Block<'_>) -> bool {
975    left.title == right.title
976        && left.title_right == right.title_right
977        && left.borders == right.borders
978        && left.border_color == right.border_color
979        && left.bg == right.bg
980        && left.style == right.style
981        && left.inner_margin == right.inner_margin
982}
983
984fn is_edge(area: Rect, x: u16, y: u16) -> bool {
985    x == area.x
986        || y == area.y
987        || x + 1 == area.x.saturating_add(area.width)
988        || y + 1 == area.y.saturating_add(area.height)
989}
990
991fn field_noise(x: u16, y: u16, tick: u64) -> u64 {
992    let mut value = u64::from(x).wrapping_mul(0x9e37_79b9_7f4a_7c15)
993        ^ u64::from(y).wrapping_mul(0xbf58_476d_1ce4_e5b9)
994        ^ tick.wrapping_mul(0x94d0_49bb_1331_11eb);
995    value ^= value >> 30;
996    value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9);
997    value ^= value >> 27;
998    value = value.wrapping_mul(0x94d0_49bb_1331_11eb);
999    value ^ (value >> 31)
1000}
1001
1002fn paint_text(area: Rect, buf: &mut Buffer, text: &str, style: Style) {
1003    if is_empty(area) {
1004        return;
1005    }
1006
1007    let right = area.x.saturating_add(area.width);
1008    for (offset, glyph) in text.chars().take(usize::from(area.width)).enumerate() {
1009        let x = area.x + offset as u16;
1010        if x >= right {
1011            break;
1012        }
1013        set_styled_char(buf, x, area.y, glyph, style);
1014    }
1015}
1016
1017fn set_cell_bg(buf: &mut Buffer, x: u16, y: u16, bg: Color) {
1018    let Some(mut cell) = buf.get(usize::from(x), usize::from(y)).copied() else {
1019        return;
1020    };
1021    cell.bg = Some(bg);
1022    buf.set(usize::from(x), usize::from(y), cell);
1023}
1024
1025fn set_cell_style(buf: &mut Buffer, x: u16, y: u16, style: Style) {
1026    let Some(cell) = buf.get(usize::from(x), usize::from(y)).copied() else {
1027        return;
1028    };
1029    buf.set(usize::from(x), usize::from(y), replace_style(cell, style));
1030}
1031
1032fn set_styled_char(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) {
1033    buf.set(
1034        usize::from(x),
1035        usize::from(y),
1036        replace_style(
1037            Cell::new(ch, style.fg.unwrap_or(Color::WHITE), style.bg),
1038            style,
1039        ),
1040    );
1041}
1042
1043fn replace_style(mut cell: Cell, style: Style) -> Cell {
1044    cell.fg = style.fg.unwrap_or(Color::WHITE);
1045    cell.bg = style.bg;
1046    cell.bold = (style.bold || style.add_modifier.contains(Modifier::BOLD))
1047        && !style.sub_modifier.contains(Modifier::BOLD);
1048    cell.italic = (style.italic || style.add_modifier.contains(Modifier::ITALIC))
1049        && !style.sub_modifier.contains(Modifier::ITALIC);
1050    cell.underlined = (style.underlined || style.add_modifier.contains(Modifier::UNDERLINED))
1051        && !style.sub_modifier.contains(Modifier::UNDERLINED);
1052    cell
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057    use super::*;
1058
1059    #[test]
1060    fn gauge_ratio_is_clamped() {
1061        assert_eq!(NebulaGauge::new(1.5).ratio(), 1.0);
1062        assert_eq!(NebulaGauge::new(-1.0).ratio(), 0.0);
1063    }
1064
1065    #[test]
1066    fn effect_can_be_applied_to_a_buffer() {
1067        let area = Rect::new(0, 0, 12, 4);
1068        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
1069
1070        AislingEffect::new(8).intensity(7).apply(area, &mut buf);
1071    }
1072
1073    #[test]
1074    fn flicker_panel_renders_without_panic() {
1075        let area = Rect::new(0, 0, 20, 3);
1076        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
1077        FlickerPanel::new("test").tick(5).intensity(3).render(&mut buf, area);
1078    }
1079
1080    #[test]
1081    fn waveform_renders_without_panic() {
1082        let area = Rect::new(0, 0, 40, 10);
1083        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
1084        Waveform::new(4.0, 0.6).tick(12).render(&mut buf, area);
1085    }
1086
1087    #[test]
1088    fn waveform_short_height_is_noop() {
1089        let area = Rect::new(0, 0, 20, 2);
1090        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
1091        Waveform::new(4.0, 0.6).render(&mut buf, area);
1092    }
1093
1094    #[test]
1095    fn pulse_ring_renders_without_panic() {
1096        let area = Rect::new(0, 0, 30, 15);
1097        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
1098        PulseRing::new(3).tick(7).render(&mut buf, area);
1099    }
1100
1101    #[test]
1102    fn pulse_ring_zero_area_is_noop() {
1103        let area = Rect::new(0, 0, 0, 0);
1104        let mut buf = Buffer::new(1, 1);
1105        PulseRing::new(5).render(&mut buf, area);
1106    }
1107}