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, Frame, Rect,
8    core::buffer::{Buffer, Cell},
9    interaction::{
10        HitRegion, MouseCursor, SelectionGroup, WidgetAction, WidgetId, WidgetRole, WidgetState,
11        WidgetValue,
12    },
13    style::{Modifier, Style},
14    theme::{Theme, ThemeTokens},
15    widgets::{
16        Widget,
17        block::{Block, BorderStyle},
18    },
19};
20
21pub use scrin;
22
23/// Common imports for apps that want the Scrin widget set plus Scrin.
24pub mod prelude {
25    pub use crate::{
26        Aisling, AislingEffect, AislingExt, AislingPalette, Align, Bordered, FlickerPanel, Gauge,
27        GlyphRain, List, NebulaGauge, NeonBorder, OrbField, Paragraph, PulseRing, Radar,
28        SignalPanel, Sparkline, SplitDirection, SplitPane, StatusBar, StreamPanel, TabBar, Table,
29        WaveType, Waveform, scrin,
30    };
31    pub use scrin::interaction::{HitRegion, WidgetId, WidgetRole};
32    pub use scrin::theme::{Theme, ThemeTokens};
33    pub use scrin::widgets::Widget;
34}
35
36/// The default color palette — cypherpunk electric tones.
37impl Default for AislingPalette {
38    fn default() -> Self {
39        Self::cypherpunk()
40    }
41}
42
43/// The default color system used by Aisling effects and bundled widgets.
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub struct AislingPalette {
46    pub low: Color,
47    pub mid: Color,
48    pub high: Color,
49    pub pulse: Color,
50    pub shadow: Color,
51}
52
53impl AislingPalette {
54    /// Electric cyan primary, acid green secondary — cypherpunk terminal core.
55    #[must_use]
56    pub const fn cypherpunk() -> Self {
57        Self {
58            low: Color::rgb(0, 230, 255),
59            mid: Color::rgb(0, 255, 136),
60            high: Color::rgb(240, 255, 255),
61            pulse: Color::rgb(255, 200, 0),
62            shadow: Color::rgb(8, 12, 20),
63        }
64    }
65
66    /// Slate blue and pale gold — cold ambient tones.
67    #[must_use]
68    pub const fn dream() -> Self {
69        Self {
70            low: Color::rgb(58, 160, 220),
71            mid: Color::rgb(120, 100, 180),
72            high: Color::rgb(220, 210, 170),
73            pulse: Color::rgb(0, 200, 200),
74            shadow: Color::rgb(12, 14, 24),
75        }
76    }
77
78    /// Green phosphor tones for surveillance or terminal-core screens.
79    #[must_use]
80    pub const fn phosphor() -> Self {
81        Self {
82            low: Color::rgb(61, 255, 142),
83            mid: Color::rgb(19, 189, 112),
84            high: Color::rgb(210, 255, 181),
85            pulse: Color::rgb(135, 255, 221),
86            shadow: Color::rgb(7, 22, 16),
87        }
88    }
89
90    /// Amber/orange warning tones for signal-heavy panels.
91    #[must_use]
92    pub const fn flare() -> Self {
93        Self {
94            low: Color::rgb(255, 170, 50),
95            mid: Color::rgb(255, 120, 30),
96            high: Color::rgb(255, 230, 140),
97            pulse: Color::rgb(255, 80, 60),
98            shadow: Color::rgb(24, 12, 8),
99        }
100    }
101
102    /// Converts the palette into Scrin's shared theme tokens.
103    #[must_use]
104    pub const fn theme_tokens(self) -> ThemeTokens {
105        ThemeTokens::new(
106            self.shadow,
107            self.high,
108            self.low,
109            self.mid,
110            self.mid,
111            self.pulse,
112            self.pulse,
113        )
114    }
115
116    /// Creates a palette from Scrin's shared theme tokens.
117    #[must_use]
118    pub const fn from_theme_tokens(tokens: ThemeTokens) -> Self {
119        Self {
120            low: tokens.dim,
121            mid: tokens.accent,
122            high: tokens.text,
123            pulse: tokens.warning,
124            shadow: tokens.panel,
125        }
126    }
127
128    /// Converts the palette into a full Scrin theme.
129    #[must_use]
130    pub const fn theme(self) -> Theme {
131        Theme {
132            bg: self.shadow,
133            fg: self.high,
134            accent: self.mid,
135            accent_bright: self.low,
136            muted: self.low,
137            surface: self.shadow,
138            surface_bright: self.shadow,
139            error: self.pulse,
140            warning: self.pulse,
141            success: self.mid,
142            info: self.low,
143            border: self.low,
144            border_focus: self.pulse,
145            text_primary: self.high,
146            text_secondary: self.mid,
147            text_dim: self.low,
148            highlight_bg: self.mid,
149            highlight_fg: self.shadow,
150            glow: self.pulse,
151        }
152    }
153
154    /// Creates a palette from Scrin's full theme shape.
155    #[must_use]
156    pub const fn from_theme(theme: Theme) -> Self {
157        Self {
158            low: theme.info,
159            mid: theme.accent,
160            high: theme.text_primary,
161            pulse: theme.glow,
162            shadow: theme.surface,
163        }
164    }
165
166    /// Builds a Scrin block styled with this palette's theme tokens.
167    #[must_use]
168    pub fn block<'a>(self, title: &'a str) -> Block<'a> {
169        Block::new(title)
170            .with_borders(BorderStyle::Plain)
171            .with_theme_tokens(self.theme_tokens())
172            .with_border_color(self.low)
173            .with_inner_margin(Rect::ZERO)
174    }
175
176    fn lane(self, value: u64) -> Color {
177        match value % 4 {
178            0 => self.low,
179            1 => self.mid,
180            2 => self.high,
181            _ => self.pulse,
182        }
183    }
184}
185
186impl From<AislingPalette> for ThemeTokens {
187    fn from(value: AislingPalette) -> Self {
188        value.theme_tokens()
189    }
190}
191
192impl From<ThemeTokens> for AislingPalette {
193    fn from(value: ThemeTokens) -> Self {
194        Self::from_theme_tokens(value)
195    }
196}
197
198impl From<AislingPalette> for Theme {
199    fn from(value: AislingPalette) -> Self {
200        value.theme()
201    }
202}
203
204impl From<Theme> for AislingPalette {
205    fn from(value: Theme) -> Self {
206        Self::from_theme(value)
207    }
208}
209
210/// A composable post-render effect that can be applied to any Scrin buffer area.
211#[derive(Clone, Copy, Debug, Eq, PartialEq)]
212pub struct AislingEffect {
213    tick: u64,
214    intensity: u16,
215    palette: AislingPalette,
216    shimmer: bool,
217    scanlines: bool,
218    glow: bool,
219}
220
221impl AislingEffect {
222    /// Creates an animated effect for the given frame or time tick.
223    #[must_use]
224    pub fn new(tick: u64) -> Self {
225        Self {
226            tick,
227            ..Self::default()
228        }
229    }
230
231    /// Sets the frame or time tick used to animate the effect.
232    #[must_use]
233    pub fn tick(mut self, tick: u64) -> Self {
234        self.tick = tick;
235        self
236    }
237
238    /// Sets the palette used by the effect.
239    #[must_use]
240    pub fn palette(mut self, palette: AislingPalette) -> Self {
241        self.palette = palette;
242        self
243    }
244
245    /// Sets effect strength on a 0..=10 scale.
246    #[must_use]
247    pub fn intensity(mut self, intensity: u16) -> Self {
248        self.intensity = intensity.min(10);
249        self
250    }
251
252    /// Enables or disables moving foreground highlights.
253    #[must_use]
254    pub fn shimmer(mut self, enabled: bool) -> Self {
255        self.shimmer = enabled;
256        self
257    }
258
259    /// Enables or disables low-contrast background scanlines.
260    #[must_use]
261    pub fn scanlines(mut self, enabled: bool) -> Self {
262        self.scanlines = enabled;
263        self
264    }
265
266    /// Enables or disables border/edge pulses.
267    #[must_use]
268    pub fn glow(mut self, enabled: bool) -> Self {
269        self.glow = enabled;
270        self
271    }
272
273    /// Applies this effect directly to an already-rendered buffer area.
274    pub fn apply(self, area: Rect, buf: &mut Buffer) {
275        if is_empty(area) || self.intensity == 0 {
276            return;
277        }
278
279        let right = area.x.saturating_add(area.width);
280        let bottom = area.y.saturating_add(area.height);
281        let edge_phase = self.tick / 2;
282        let shimmer_gate = 11_u64.saturating_sub(u64::from(self.intensity.min(10)));
283
284        for y in area.y..bottom {
285            for x in area.x..right {
286                if self.scanlines && (u64::from(y) + edge_phase).is_multiple_of(3) {
287                    set_cell_bg(buf, x, y, self.palette.shadow);
288                }
289
290                if self.shimmer {
291                    let phase = u64::from(x) * 3 + u64::from(y) * 5 + self.tick;
292                    if phase % 11 >= shimmer_gate {
293                        set_cell_style(
294                            buf,
295                            x,
296                            y,
297                            Style::default()
298                                .fg(self.palette.lane(phase))
299                                .add_modifier(Modifier::BOLD),
300                        );
301                    }
302                }
303
304                if self.glow
305                    && is_edge(area, x, y)
306                    && (u64::from(x) + u64::from(y) + edge_phase) % 5 == 0
307                {
308                    set_cell_style(
309                        buf,
310                        x,
311                        y,
312                        Style::default()
313                            .fg(self.palette.pulse)
314                            .add_modifier(Modifier::BOLD),
315                    );
316                }
317            }
318        }
319    }
320}
321
322impl Default for AislingEffect {
323    fn default() -> Self {
324        Self {
325            tick: 0,
326            intensity: 5,
327            palette: AislingPalette::default(),
328            shimmer: true,
329            scanlines: true,
330            glow: true,
331        }
332    }
333}
334
335/// Wraps any Scrin widget with an Aisling post-render effect.
336#[derive(Clone, Debug, Eq, PartialEq)]
337pub struct Aisling<W> {
338    inner: W,
339    effect: AislingEffect,
340}
341
342impl<W> Aisling<W> {
343    /// Creates an effect wrapper around any Scrin widget.
344    #[must_use]
345    pub fn new(inner: W) -> Self {
346        Self {
347            inner,
348            effect: AislingEffect::default(),
349        }
350    }
351
352    /// Replaces the effect used by the wrapper.
353    #[must_use]
354    pub fn effect(mut self, effect: AislingEffect) -> Self {
355        self.effect = effect;
356        self
357    }
358
359    /// Sets the animation tick.
360    #[must_use]
361    pub fn tick(mut self, tick: u64) -> Self {
362        self.effect = self.effect.tick(tick);
363        self
364    }
365
366    /// Sets the palette.
367    #[must_use]
368    pub fn palette(mut self, palette: AislingPalette) -> Self {
369        self.effect = self.effect.palette(palette);
370        self
371    }
372
373    /// Sets effect strength on a 0..=10 scale.
374    #[must_use]
375    pub fn intensity(mut self, intensity: u16) -> Self {
376        self.effect = self.effect.intensity(intensity);
377        self
378    }
379}
380
381impl<W: Widget> Widget for Aisling<W> {
382    fn render(&self, buf: &mut Buffer, area: Rect) {
383        self.inner.render(buf, area);
384        self.effect.apply(area, buf);
385    }
386}
387
388/// Extension trait for calling `.aisling()` on any Scrin widget.
389pub trait AislingExt: Widget + Sized {
390    /// Decorates this widget with the default Aisling effect.
391    #[must_use]
392    fn aisling(self) -> Aisling<Self> {
393        Aisling::new(self)
394    }
395}
396
397impl<W: Widget> AislingExt for W {}
398
399/// A deterministic matrix/rain field for ambient Scrin backgrounds.
400#[derive(Clone, Debug)]
401pub struct GlyphRain<'a> {
402    tick: u64,
403    density: u16,
404    glyphs: Cow<'a, str>,
405    palette: AislingPalette,
406    block: Option<Block<'a>>,
407}
408
409impl PartialEq for GlyphRain<'_> {
410    fn eq(&self, other: &Self) -> bool {
411        self.tick == other.tick
412            && self.density == other.density
413            && self.glyphs == other.glyphs
414            && self.palette == other.palette
415            && option_block_eq(self.block.as_ref(), other.block.as_ref())
416    }
417}
418
419impl Eq for GlyphRain<'_> {}
420
421impl<'a> GlyphRain<'a> {
422    /// Creates a glyph rain widget for the given animation tick.
423    #[must_use]
424    pub fn new(tick: u64) -> Self {
425        Self {
426            tick,
427            density: 34,
428            glyphs: Cow::Borrowed("01#$*+<>[]{}"),
429            palette: AislingPalette::phosphor(),
430            block: None,
431        }
432    }
433
434    /// Sets the animation tick.
435    #[must_use]
436    pub fn tick(mut self, tick: u64) -> Self {
437        self.tick = tick;
438        self
439    }
440
441    /// Sets density on a 0..=100 scale.
442    #[must_use]
443    pub fn density(mut self, density: u16) -> Self {
444        self.density = density.min(100);
445        self
446    }
447
448    /// Sets the glyph alphabet used by the rain field.
449    #[must_use]
450    pub fn glyphs(mut self, glyphs: impl Into<Cow<'a, str>>) -> Self {
451        self.glyphs = glyphs.into();
452        self
453    }
454
455    /// Sets the color palette.
456    #[must_use]
457    pub fn palette(mut self, palette: AislingPalette) -> Self {
458        self.palette = palette;
459        self
460    }
461
462    /// Adds a block around the field.
463    #[must_use]
464    pub fn block(mut self, block: Block<'a>) -> Self {
465        self.block = Some(block);
466        self
467    }
468}
469
470impl Widget for GlyphRain<'_> {
471    fn render(&self, buf: &mut Buffer, area: Rect) {
472        let inner = self
473            .block
474            .as_ref()
475            .map_or(area, |block| block_content_area(block, area));
476        if let Some(block) = &self.block {
477            block.render(buf, area);
478        }
479        if is_empty(inner) || self.density == 0 {
480            return;
481        }
482
483        let glyphs: Vec<char> = self.glyphs.chars().collect();
484        if glyphs.is_empty() {
485            return;
486        }
487
488        let right = inner.x.saturating_add(inner.width);
489        let bottom = inner.y.saturating_add(inner.height);
490        for y in inner.y..bottom {
491            for x in inner.x..right {
492                let noise = field_noise(x, y, self.tick);
493                if noise % 100 >= u64::from(self.density) {
494                    continue;
495                }
496
497                let glyph = glyphs[(noise as usize + usize::from(y)) % glyphs.len()];
498                let head = (noise + self.tick) % 9 == 0;
499                let style = if head {
500                    Style::default()
501                        .fg(self.palette.high)
502                        .add_modifier(Modifier::BOLD)
503                } else {
504                    Style::default().fg(self.palette.lane(noise + self.tick))
505                };
506
507                set_styled_char(buf, x, y, glyph, style);
508            }
509        }
510    }
511}
512
513/// A compact progress gauge with a flowing nebula fill.
514#[derive(Clone, Debug)]
515pub struct NebulaGauge<'a> {
516    ratio: f64,
517    tick: u64,
518    label: Option<Cow<'a, str>>,
519    palette: AislingPalette,
520    block: Option<Block<'a>>,
521}
522
523impl PartialEq for NebulaGauge<'_> {
524    fn eq(&self, other: &Self) -> bool {
525        self.ratio == other.ratio
526            && self.tick == other.tick
527            && self.label == other.label
528            && self.palette == other.palette
529            && option_block_eq(self.block.as_ref(), other.block.as_ref())
530    }
531}
532
533impl<'a> NebulaGauge<'a> {
534    /// Creates a gauge with a ratio clamped to 0.0..=1.0.
535    #[must_use]
536    pub fn new(ratio: f64) -> Self {
537        Self {
538            ratio: ratio.clamp(0.0, 1.0),
539            tick: 0,
540            label: None,
541            palette: AislingPalette::dream(),
542            block: None,
543        }
544    }
545
546    /// Returns the clamped ratio.
547    #[must_use]
548    pub fn ratio(&self) -> f64 {
549        self.ratio
550    }
551
552    /// Sets the animation tick.
553    #[must_use]
554    pub fn tick(mut self, tick: u64) -> Self {
555        self.tick = tick;
556        self
557    }
558
559    /// Sets the centered label.
560    #[must_use]
561    pub fn label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
562        self.label = Some(label.into());
563        self
564    }
565
566    /// Sets the color palette.
567    #[must_use]
568    pub fn palette(mut self, palette: AislingPalette) -> Self {
569        self.palette = palette;
570        self
571    }
572
573    /// Adds a block around the gauge.
574    #[must_use]
575    pub fn block(mut self, block: Block<'a>) -> Self {
576        self.block = Some(block);
577        self
578    }
579}
580
581impl Widget for NebulaGauge<'_> {
582    fn render(&self, buf: &mut Buffer, area: Rect) {
583        let inner = self
584            .block
585            .as_ref()
586            .map_or(area, |block| block_content_area(block, area));
587        if let Some(block) = &self.block {
588            block.render(buf, area);
589        }
590        if is_empty(inner) {
591            return;
592        }
593
594        let right = inner.x.saturating_add(inner.width);
595        let bottom = inner.y.saturating_add(inner.height);
596        let filled = (f64::from(inner.width) * self.ratio).round() as u16;
597
598        for y in inner.y..bottom {
599            for x in inner.x..right {
600                let offset = x.saturating_sub(inner.x);
601                let flow = u64::from(offset) + u64::from(y) * 2 + self.tick;
602                if offset < filled {
603                    set_styled_char(
604                        buf,
605                        x,
606                        y,
607                        '█',
608                        Style::default()
609                            .fg(self.palette.lane(flow))
610                            .bg(self.palette.shadow)
611                            .add_modifier(Modifier::BOLD),
612                    );
613                } else {
614                    set_styled_char(buf, x, y, '░', Style::default().fg(self.palette.shadow));
615                }
616            }
617        }
618
619        if let Some(label) = &self.label {
620            let row = inner.y + inner.height / 2;
621            let label_width = label.chars().count().min(usize::from(inner.width)) as u16;
622            let start = inner.x + inner.width.saturating_sub(label_width) / 2;
623            paint_text(
624                Rect::new(start, row, label_width, 1),
625                buf,
626                label.as_ref(),
627                Style::default()
628                    .fg(self.palette.high)
629                    .add_modifier(Modifier::BOLD),
630            );
631        }
632    }
633}
634
635/// A bordered status panel with animated signal bars.
636#[derive(Clone, Debug, Eq, PartialEq)]
637pub struct SignalPanel<'a> {
638    title: Cow<'a, str>,
639    lines: Vec<Cow<'a, str>>,
640    tick: u64,
641    palette: AislingPalette,
642}
643
644impl<'a> SignalPanel<'a> {
645    /// Creates a signal panel with a title.
646    #[must_use]
647    pub fn new(title: impl Into<Cow<'a, str>>) -> Self {
648        Self {
649            title: title.into(),
650            lines: Vec::new(),
651            tick: 0,
652            palette: AislingPalette::flare(),
653        }
654    }
655
656    /// Adds one body line.
657    #[must_use]
658    pub fn line(mut self, line: impl Into<Cow<'a, str>>) -> Self {
659        self.lines.push(line.into());
660        self
661    }
662
663    /// Replaces all body lines.
664    #[must_use]
665    pub fn lines<I, S>(mut self, lines: I) -> Self
666    where
667        I: IntoIterator<Item = S>,
668        S: Into<Cow<'a, str>>,
669    {
670        self.lines = lines.into_iter().map(Into::into).collect();
671        self
672    }
673
674    /// Sets the animation tick.
675    #[must_use]
676    pub fn tick(mut self, tick: u64) -> Self {
677        self.tick = tick;
678        self
679    }
680
681    /// Sets the color palette.
682    #[must_use]
683    pub fn palette(mut self, palette: AislingPalette) -> Self {
684        self.palette = palette;
685        self
686    }
687}
688
689impl Widget for SignalPanel<'_> {
690    fn render(&self, buf: &mut Buffer, area: Rect) {
691        if is_empty(area) {
692            return;
693        }
694
695        let block = Block::new(self.title.as_ref())
696            .with_borders(BorderStyle::Plain)
697            .with_border_color(self.palette.mid)
698            .with_inner_margin(Rect::ZERO);
699        let inner = block_content_area(&block, area);
700        block.render(buf, area);
701        if is_empty(inner) {
702            return;
703        }
704
705        let bars_width = inner.width.min(12);
706        let text_width = inner.width.saturating_sub(bars_width.saturating_add(1));
707        let max_lines = usize::from(inner.height);
708
709        for (index, line) in self.lines.iter().take(max_lines).enumerate() {
710            paint_text(
711                Rect::new(inner.x, inner.y + index as u16, text_width, 1),
712                buf,
713                line.as_ref(),
714                Style::default().fg(self.palette.high),
715            );
716        }
717
718        if bars_width == 0 {
719            return;
720        }
721
722        let bars_x = inner.x + inner.width.saturating_sub(bars_width);
723        for row in 0..inner.height {
724            for column in 0..bars_width {
725                let x = bars_x + column;
726                let y = inner.y + row;
727                let noise = field_noise(x, y, self.tick / 2);
728                let active = (noise + self.tick + u64::from(column)) % 7 <= 3;
729                let symbol = if active { '╱' } else { '·' };
730                let style = if active {
731                    Style::default()
732                        .fg(self.palette.lane(noise))
733                        .add_modifier(Modifier::BOLD)
734                } else {
735                    Style::default().fg(self.palette.shadow)
736                };
737                set_styled_char(buf, x, y, symbol, style);
738            }
739        }
740    }
741}
742
743/// A text panel with per-character flicker and glitch effects.
744#[derive(Clone, Debug)]
745pub struct FlickerPanel<'a> {
746    text: Cow<'a, str>,
747    tick: u64,
748    intensity: u16,
749    palette: AislingPalette,
750    block: Option<Block<'a>>,
751}
752
753impl PartialEq for FlickerPanel<'_> {
754    fn eq(&self, other: &Self) -> bool {
755        self.text == other.text
756            && self.tick == other.tick
757            && self.intensity == other.intensity
758            && self.palette == other.palette
759            && option_block_eq(self.block.as_ref(), other.block.as_ref())
760    }
761}
762
763impl Eq for FlickerPanel<'_> {}
764
765impl<'a> FlickerPanel<'a> {
766    /// Creates a flicker panel with the given text.
767    #[must_use]
768    pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
769        Self {
770            text: text.into(),
771            tick: 0,
772            intensity: 5,
773            palette: AislingPalette::dream(),
774            block: None,
775        }
776    }
777
778    /// Sets the animation tick.
779    #[must_use]
780    pub fn tick(mut self, tick: u64) -> Self {
781        self.tick = tick;
782        self
783    }
784
785    /// Sets glitch intensity on a 0..=10 scale (0 = no glitch).
786    #[must_use]
787    pub fn intensity(mut self, intensity: u16) -> Self {
788        self.intensity = intensity.min(10);
789        self
790    }
791
792    /// Sets the color palette.
793    #[must_use]
794    pub fn palette(mut self, palette: AislingPalette) -> Self {
795        self.palette = palette;
796        self
797    }
798
799    /// Adds a block around the panel.
800    #[must_use]
801    pub fn block(mut self, block: Block<'a>) -> Self {
802        self.block = Some(block);
803        self
804    }
805}
806
807impl Widget for FlickerPanel<'_> {
808    fn render(&self, buf: &mut Buffer, area: Rect) {
809        let inner = self
810            .block
811            .as_ref()
812            .map_or(area, |block| block_content_area(block, area));
813        if let Some(block) = &self.block {
814            block.render(buf, area);
815        }
816        if is_empty(inner) || self.intensity == 0 {
817            return;
818        }
819
820        let glitch_chars: Vec<char> = "░▒▓█▀▄▌▐│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬".chars().collect();
821        let text_chars: Vec<char> = self.text.chars().collect();
822        if text_chars.is_empty() {
823            return;
824        }
825
826        let right = inner.x.saturating_add(inner.width);
827        let bottom = inner.y.saturating_add(inner.height);
828        for y in inner.y..bottom {
829            for x in inner.x..right {
830                let col = usize::from(x.saturating_sub(inner.x));
831                let noise = field_noise(x, y, self.tick);
832                let glitch_gate = 11_u64.saturating_sub(u64::from(self.intensity));
833
834                let (ch, style) = if noise % 11 >= glitch_gate {
835                    let g = glitch_chars[(noise as usize) % glitch_chars.len()];
836                    (
837                        g,
838                        Style::default()
839                            .fg(self.palette.pulse)
840                            .add_modifier(Modifier::BOLD),
841                    )
842                } else if col < text_chars.len() {
843                    let c = text_chars[col];
844                    let flicker = (noise + self.tick) % 9 == 0;
845                    let style = if flicker {
846                        Style::default()
847                            .fg(self.palette.high)
848                            .add_modifier(Modifier::BOLD)
849                    } else {
850                        Style::default().fg(self.palette.mid)
851                    };
852                    (c, style)
853                } else {
854                    (' ', Style::default())
855                };
856                set_styled_char(buf, x, y, ch, style);
857            }
858        }
859    }
860}
861
862/// An animated oscilloscope / waveform display.
863#[derive(Clone, Debug)]
864pub struct Waveform<'a> {
865    tick: u64,
866    frequency: f64,
867    amplitude: f64,
868    wave_type: WaveType,
869    palette: AislingPalette,
870    block: Option<Block<'a>>,
871}
872
873#[derive(Clone, Copy, Debug, Eq, PartialEq)]
874pub enum WaveType {
875    Sine,
876    Square,
877    Sawtooth,
878    Triangle,
879}
880
881impl PartialEq for Waveform<'_> {
882    fn eq(&self, other: &Self) -> bool {
883        self.tick == other.tick
884            && self.frequency == other.frequency
885            && self.amplitude == other.amplitude
886            && self.wave_type == other.wave_type
887            && self.palette == other.palette
888            && option_block_eq(self.block.as_ref(), other.block.as_ref())
889    }
890}
891
892impl Eq for Waveform<'_> {}
893
894impl<'a> Waveform<'a> {
895    /// Creates a waveform with the given frequency and amplitude.
896    #[must_use]
897    pub fn new(frequency: f64, amplitude: f64) -> Self {
898        Self {
899            tick: 0,
900            frequency,
901            amplitude: amplitude.clamp(0.0, 1.0),
902            wave_type: WaveType::Sine,
903            palette: AislingPalette::phosphor(),
904            block: None,
905        }
906    }
907
908    /// Sets the animation tick.
909    #[must_use]
910    pub fn tick(mut self, tick: u64) -> Self {
911        self.tick = tick;
912        self
913    }
914
915    /// Sets the wave type.
916    #[must_use]
917    pub fn wave_type(mut self, wave_type: WaveType) -> Self {
918        self.wave_type = wave_type;
919        self
920    }
921
922    /// Sets the color palette.
923    #[must_use]
924    pub fn palette(mut self, palette: AislingPalette) -> Self {
925        self.palette = palette;
926        self
927    }
928
929    /// Adds a block around the waveform.
930    #[must_use]
931    pub fn block(mut self, block: Block<'a>) -> Self {
932        self.block = Some(block);
933        self
934    }
935
936    fn sample(&self, phase: f64) -> f64 {
937        let t = phase.fract();
938        match self.wave_type {
939            WaveType::Sine => (std::f64::consts::TAU * t).sin(),
940            WaveType::Square => {
941                if t < 0.5 {
942                    1.0
943                } else {
944                    -1.0
945                }
946            }
947            WaveType::Sawtooth => 2.0 * t - 1.0,
948            WaveType::Triangle => {
949                if t < 0.5 {
950                    4.0 * t - 1.0
951                } else {
952                    3.0 - 4.0 * t
953                }
954            }
955        }
956    }
957}
958
959impl Widget for Waveform<'_> {
960    fn render(&self, buf: &mut Buffer, area: Rect) {
961        let inner = self
962            .block
963            .as_ref()
964            .map_or(area, |block| block_content_area(block, area));
965        if let Some(block) = &self.block {
966            block.render(buf, area);
967        }
968        if is_empty(inner) || inner.height < 3 {
969            return;
970        }
971
972        let mid_y = inner.y + inner.height / 2;
973        let half = (inner.height / 2) as f64;
974
975        for col in 0..inner.width {
976            let phase = f64::from(col) / f64::from(inner.width) * self.frequency
977                + f64::from(self.tick as u32) * 0.05;
978            let sample = self.sample(phase);
979            let offset = (sample * self.amplitude * half).round() as i16;
980
981            let y = mid_y as i16 + offset;
982            if y >= inner.y as i16 && y < (inner.y + inner.height) as i16 {
983                let noise = field_noise(inner.x + col, y as u16, self.tick);
984                set_styled_char(
985                    buf,
986                    inner.x + col,
987                    y as u16,
988                    '█',
989                    Style::default()
990                        .fg(self.palette.lane(noise))
991                        .add_modifier(Modifier::BOLD),
992                );
993            }
994        }
995    }
996}
997
998/// An animated expanding concentric ring effect.
999#[derive(Clone, Debug, Eq, PartialEq)]
1000pub struct PulseRing {
1001    tick: u64,
1002    rings: u16,
1003    palette: AislingPalette,
1004}
1005
1006impl PulseRing {
1007    /// Creates a pulse ring with the given number of concentric rings.
1008    #[must_use]
1009    pub fn new(rings: u16) -> Self {
1010        Self {
1011            tick: 0,
1012            rings: rings.max(1),
1013            palette: AislingPalette::dream(),
1014        }
1015    }
1016
1017    /// Sets the animation tick.
1018    #[must_use]
1019    pub fn tick(mut self, tick: u64) -> Self {
1020        self.tick = tick;
1021        self
1022    }
1023
1024    /// Sets the color palette.
1025    #[must_use]
1026    pub fn palette(mut self, palette: AislingPalette) -> Self {
1027        self.palette = palette;
1028        self
1029    }
1030}
1031
1032impl Widget for PulseRing {
1033    fn render(&self, buf: &mut Buffer, area: Rect) {
1034        if is_empty(area) || self.rings == 0 {
1035            return;
1036        }
1037
1038        let cx = area.x + area.width / 2;
1039        let cy = area.y + area.height / 2;
1040        let max_radius = (area.width.min(area.height) / 2) as f64;
1041        if max_radius < 1.0 {
1042            return;
1043        }
1044
1045        let right = area.x.saturating_add(area.width);
1046        let bottom = area.y.saturating_add(area.height);
1047
1048        for y in area.y..bottom {
1049            for x in area.x..right {
1050                let dx = x as f64 - cx as f64;
1051                let dy = y as f64 - cy as f64;
1052                let dist = (dx * dx + dy * dy).sqrt();
1053
1054                for ring in 0..self.rings {
1055                    let ring_phase = (self.tick as f64 * 0.1 + ring as f64 * 3.0) % max_radius;
1056                    let diff = (dist - ring_phase).abs();
1057                    if diff < 1.5 {
1058                        let noise = field_noise(x, y, self.tick + ring as u64);
1059                        let style = if diff < 0.8 {
1060                            Style::default()
1061                                .fg(self.palette.high)
1062                                .add_modifier(Modifier::BOLD)
1063                        } else {
1064                            Style::default().fg(self.palette.lane(noise))
1065                        };
1066                        set_styled_char(buf, x, y, '○', style);
1067                        break;
1068                    }
1069                }
1070            }
1071        }
1072    }
1073}
1074
1075/// A rotating radar sweep with a fade trail.
1076#[derive(Clone, Debug, Eq, PartialEq)]
1077pub struct Radar {
1078    tick: u64,
1079    sweep_speed: u64,
1080    palette: AislingPalette,
1081}
1082
1083impl Radar {
1084    /// Creates a radar widget. `sweep_speed` controls rotation speed (higher = slower).
1085    #[must_use]
1086    pub fn new(sweep_speed: u64) -> Self {
1087        Self {
1088            tick: 0,
1089            sweep_speed: sweep_speed.max(1),
1090            palette: AislingPalette::phosphor(),
1091        }
1092    }
1093
1094    /// Sets the animation tick.
1095    #[must_use]
1096    pub fn tick(mut self, tick: u64) -> Self {
1097        self.tick = tick;
1098        self
1099    }
1100
1101    /// Sets the color palette.
1102    #[must_use]
1103    pub fn palette(mut self, palette: AislingPalette) -> Self {
1104        self.palette = palette;
1105        self
1106    }
1107}
1108
1109impl Widget for Radar {
1110    fn render(&self, buf: &mut Buffer, area: Rect) {
1111        if is_empty(area) {
1112            return;
1113        }
1114
1115        let cx = area.x + area.width / 2;
1116        let cy = area.y + area.height / 2;
1117        let max_r = (area.width.min(area.height) / 2) as f64;
1118        if max_r < 1.0 {
1119            return;
1120        }
1121
1122        let sweep_angle = (self.tick as f64 / self.sweep_speed as f64) % (std::f64::consts::TAU);
1123        let trail_len = std::f64::consts::PI * 0.6;
1124
1125        let right = area.x.saturating_add(area.width);
1126        let bottom = area.y.saturating_add(area.height);
1127
1128        for y in area.y..bottom {
1129            for x in area.x..right {
1130                let dx = x as f64 - cx as f64;
1131                let dy = y as f64 - cy as f64;
1132                let dist = (dx * dx + dy * dy).sqrt();
1133
1134                if dist > max_r {
1135                    continue;
1136                }
1137
1138                let angle = dy.atan2(dx);
1139                let norm_angle = if angle < 0.0 {
1140                    angle + std::f64::consts::TAU
1141                } else {
1142                    angle
1143                };
1144
1145                let ring = dist as u16;
1146                if ring > 0 && dist % (max_r / 3.0) < 0.5 {
1147                    set_styled_char(buf, x, y, '·', Style::default().fg(self.palette.shadow));
1148                    continue;
1149                }
1150
1151                let diff = (norm_angle - sweep_angle).abs();
1152                let diff = if diff > std::f64::consts::PI {
1153                    std::f64::consts::TAU - diff
1154                } else {
1155                    diff
1156                };
1157
1158                if diff < trail_len {
1159                    let fade = 1.0 - diff / trail_len;
1160                    let noise = field_noise(x, y, self.tick);
1161                    let color = if fade > 0.6 {
1162                        self.palette.high
1163                    } else {
1164                        self.palette.lane(noise)
1165                    };
1166                    set_styled_char(
1167                        buf,
1168                        x,
1169                        y,
1170                        if dist < 1.0 { '●' } else { '·' },
1171                        Style::default().fg(color).add_modifier(Modifier::BOLD),
1172                    );
1173                } else if (dist - 1.0).abs() < 0.5
1174                    || (dist - max_r * 0.5).abs() < 0.5
1175                    || (dist - max_r * 0.9).abs() < 0.5
1176                {
1177                    set_styled_char(buf, x, y, '·', Style::default().fg(self.palette.shadow));
1178                }
1179            }
1180        }
1181    }
1182}
1183
1184/// Floating animated orbs / particles.
1185#[derive(Clone, Debug)]
1186pub struct OrbField<'a> {
1187    tick: u64,
1188    count: u16,
1189    palette: AislingPalette,
1190    block: Option<Block<'a>>,
1191}
1192
1193impl PartialEq for OrbField<'_> {
1194    fn eq(&self, other: &Self) -> bool {
1195        self.tick == other.tick
1196            && self.count == other.count
1197            && self.palette == other.palette
1198            && option_block_eq(self.block.as_ref(), other.block.as_ref())
1199    }
1200}
1201
1202impl Eq for OrbField<'_> {}
1203
1204impl<'a> OrbField<'a> {
1205    /// Creates an orb field with the given number of particles.
1206    #[must_use]
1207    pub fn new(count: u16) -> Self {
1208        Self {
1209            tick: 0,
1210            count,
1211            palette: AislingPalette::dream(),
1212            block: None,
1213        }
1214    }
1215
1216    /// Sets the animation tick.
1217    #[must_use]
1218    pub fn tick(mut self, tick: u64) -> Self {
1219        self.tick = tick;
1220        self
1221    }
1222
1223    /// Sets the color palette.
1224    #[must_use]
1225    pub fn palette(mut self, palette: AislingPalette) -> Self {
1226        self.palette = palette;
1227        self
1228    }
1229
1230    /// Adds a block around the field.
1231    #[must_use]
1232    pub fn block(mut self, block: Block<'a>) -> Self {
1233        self.block = Some(block);
1234        self
1235    }
1236}
1237
1238impl Widget for OrbField<'_> {
1239    fn render(&self, buf: &mut Buffer, area: Rect) {
1240        let inner = self
1241            .block
1242            .as_ref()
1243            .map_or(area, |block| block_content_area(block, area));
1244        if let Some(block) = &self.block {
1245            block.render(buf, area);
1246        }
1247        if is_empty(inner) || self.count == 0 {
1248            return;
1249        }
1250
1251        for i in 0..u64::from(self.count) {
1252            let seed = field_noise(i as u16, i as u16 / 7, i);
1253            let speed_x = (seed % 7) as f64 * 0.3 + 0.2;
1254            let speed_y = ((seed >> 3) % 5) as f64 * 0.2 + 0.1;
1255            let phase_x = seed as f64 * 0.1;
1256            let phase_y = (seed >> 5) as f64 * 0.13;
1257
1258            let base_x = (inner.x as f64)
1259                + ((self.tick as f64 * speed_x * 0.02 + phase_x).sin() * 0.5 + 0.5)
1260                    * inner.width as f64;
1261            let base_y = (inner.y as f64)
1262                + ((self.tick as f64 * speed_y * 0.02 + phase_y).cos() * 0.5 + 0.5)
1263                    * inner.height as f64;
1264
1265            let px = base_x.round() as u16;
1266            let py = base_y.round() as u16;
1267
1268            if px >= inner.x
1269                && px < inner.x + inner.width
1270                && py >= inner.y
1271                && py < inner.y + inner.height
1272            {
1273                let noise = field_noise(px, py, self.tick + i);
1274                let glyph = if noise % 3 == 0 {
1275                    '◆'
1276                } else if noise % 3 == 1 {
1277                    '◇'
1278                } else {
1279                    '•'
1280                };
1281                set_styled_char(
1282                    buf,
1283                    px,
1284                    py,
1285                    glyph,
1286                    Style::default()
1287                        .fg(self.palette.lane(i))
1288                        .add_modifier(Modifier::BOLD),
1289                );
1290            }
1291        }
1292    }
1293}
1294
1295/// An animated neon border that cycles colors around a block.
1296#[derive(Clone, Debug)]
1297pub struct NeonBorder<'a> {
1298    tick: u64,
1299    speed: u64,
1300    palette: AislingPalette,
1301    inner: Block<'a>,
1302}
1303
1304impl PartialEq for NeonBorder<'_> {
1305    fn eq(&self, other: &Self) -> bool {
1306        self.tick == other.tick
1307            && self.speed == other.speed
1308            && self.palette == other.palette
1309            && block_eq(&self.inner, &other.inner)
1310    }
1311}
1312
1313impl<'a> NeonBorder<'a> {
1314    /// Creates a neon border wrapper around an existing block.
1315    #[must_use]
1316    pub fn new(inner: Block<'a>) -> Self {
1317        Self {
1318            tick: 0,
1319            speed: 3,
1320            palette: AislingPalette::dream(),
1321            inner,
1322        }
1323    }
1324
1325    /// Sets the animation tick.
1326    #[must_use]
1327    pub fn tick(mut self, tick: u64) -> Self {
1328        self.tick = tick;
1329        self
1330    }
1331
1332    /// Sets the cycle speed (higher = slower).
1333    #[must_use]
1334    pub fn speed(mut self, speed: u64) -> Self {
1335        self.speed = speed.max(1);
1336        self
1337    }
1338
1339    /// Sets the color palette.
1340    #[must_use]
1341    pub fn palette(mut self, palette: AislingPalette) -> Self {
1342        self.palette = palette;
1343        self
1344    }
1345
1346    /// Renders just the animated border, returning the inner content area.
1347    pub fn render_border(&self, buf: &mut Buffer, area: Rect) -> Rect {
1348        let inner = block_content_area(&self.inner, area);
1349
1350        let right = area.x.saturating_add(area.width);
1351        let bottom = area.y.saturating_add(area.height);
1352        let perimeter = 2 * (area.width + area.height) as u64;
1353
1354        for y in area.y..bottom {
1355            for x in area.x..right {
1356                if !is_edge(area, x, y) {
1357                    continue;
1358                }
1359
1360                let pos = if y == area.y {
1361                    u64::from(x - area.x)
1362                } else if x + 1 == right {
1363                    u64::from(area.width) + u64::from(y - area.y)
1364                } else if y + 1 == bottom {
1365                    u64::from(area.width + area.height) + u64::from(right - x - 1)
1366                } else {
1367                    u64::from(2 * area.width + area.height) + u64::from(bottom - y - 1)
1368                };
1369
1370                let phase = (self.tick * self.speed + pos) % perimeter;
1371                let color_idx = (phase * 4 / perimeter) as usize;
1372                let color = match color_idx {
1373                    0 => self.palette.low,
1374                    1 => self.palette.mid,
1375                    2 => self.palette.high,
1376                    _ => self.palette.pulse,
1377                };
1378
1379                let ch = if y == area.y || y + 1 == bottom {
1380                    '─'
1381                } else {
1382                    '│'
1383                };
1384                set_styled_char(
1385                    buf,
1386                    x,
1387                    y,
1388                    ch,
1389                    Style::default().fg(color).add_modifier(Modifier::BOLD),
1390                );
1391            }
1392        }
1393
1394        inner
1395    }
1396}
1397
1398impl Widget for NeonBorder<'_> {
1399    fn render(&self, buf: &mut Buffer, area: Rect) {
1400        self.render_border(buf, area);
1401    }
1402}
1403
1404// ---------------------------------------------------------------------------
1405// StreamPanel — streaming log / code output with auto-scroll and line numbers
1406// ---------------------------------------------------------------------------
1407
1408/// A streaming log or code output panel with auto-scroll and optional line numbers.
1409///
1410/// Feed it lines via `.lines()` and control the scroll with `.scroll_offset()`.
1411/// When `follow_tail` is true (default), new lines auto-scroll to the bottom.
1412#[derive(Clone, Debug)]
1413pub struct StreamPanel<'a> {
1414    lines: Vec<Cow<'a, str>>,
1415    scroll_offset: u16,
1416    follow_tail: bool,
1417    show_line_numbers: bool,
1418    tick: u64,
1419    palette: AislingPalette,
1420    block: Option<Block<'a>>,
1421}
1422
1423impl PartialEq for StreamPanel<'_> {
1424    fn eq(&self, other: &Self) -> bool {
1425        self.lines == other.lines
1426            && self.scroll_offset == other.scroll_offset
1427            && self.follow_tail == other.follow_tail
1428            && self.show_line_numbers == other.show_line_numbers
1429            && self.tick == other.tick
1430            && self.palette == other.palette
1431            && option_block_eq(self.block.as_ref(), other.block.as_ref())
1432    }
1433}
1434
1435impl<'a> StreamPanel<'a> {
1436    /// Creates an empty stream panel.
1437    #[must_use]
1438    pub fn new() -> Self {
1439        Self {
1440            lines: Vec::new(),
1441            scroll_offset: 0,
1442            follow_tail: true,
1443            show_line_numbers: false,
1444            tick: 0,
1445            palette: AislingPalette::phosphor(),
1446            block: None,
1447        }
1448    }
1449
1450    /// Replaces all lines.
1451    #[must_use]
1452    pub fn lines<I, S>(mut self, lines: I) -> Self
1453    where
1454        I: IntoIterator<Item = S>,
1455        S: Into<Cow<'a, str>>,
1456    {
1457        self.lines = lines.into_iter().map(Into::into).collect();
1458        self
1459    }
1460
1461    /// Appends a single line.
1462    #[must_use]
1463    pub fn push_line(mut self, line: impl Into<Cow<'a, str>>) -> Self {
1464        self.lines.push(line.into());
1465        self
1466    }
1467
1468    /// Sets the scroll offset from the bottom (0 = last line at bottom).
1469    #[must_use]
1470    pub fn scroll_offset(mut self, offset: u16) -> Self {
1471        self.scroll_offset = offset;
1472        self
1473    }
1474
1475    /// When true, the view follows the last line (default: true).
1476    #[must_use]
1477    pub fn follow_tail(mut self, follow: bool) -> Self {
1478        self.follow_tail = follow;
1479        self
1480    }
1481
1482    /// Shows line numbers in the gutter (default: false).
1483    #[must_use]
1484    pub fn show_line_numbers(mut self, show: bool) -> Self {
1485        self.show_line_numbers = show;
1486        self
1487    }
1488
1489    /// Sets the animation tick.
1490    #[must_use]
1491    pub fn tick(mut self, tick: u64) -> Self {
1492        self.tick = tick;
1493        self
1494    }
1495
1496    /// Sets the color palette.
1497    #[must_use]
1498    pub fn palette(mut self, palette: AislingPalette) -> Self {
1499        self.palette = palette;
1500        self
1501    }
1502
1503    /// Adds a block around the panel.
1504    #[must_use]
1505    pub fn block(mut self, block: Block<'a>) -> Self {
1506        self.block = Some(block);
1507        self
1508    }
1509
1510    /// Renders into a Scrin frame and registers hit regions for visible rows.
1511    pub fn render_with_interaction(
1512        &self,
1513        frame: &mut Frame<'_>,
1514        id: impl Into<WidgetId>,
1515        area: Rect,
1516    ) {
1517        self.render(frame.buffer(), area);
1518        for region in self.hit_regions(id, area) {
1519            frame.register_hit_region(region);
1520        }
1521        frame.mark_dirty(area);
1522    }
1523
1524    /// Builds Scrin interaction metadata for the visible stream rows.
1525    #[must_use]
1526    pub fn hit_regions(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<HitRegion> {
1527        let region_id = id.into();
1528        let inner = self
1529            .block
1530            .as_ref()
1531            .map_or(area, |block| block_content_area(block, area));
1532        if is_empty(area) || is_empty(inner) {
1533            return Vec::new();
1534        }
1535
1536        let mut regions = vec![
1537            HitRegion::new(region_id.clone(), area)
1538                .with_role(WidgetRole::Transcript)
1539                .with_label("stream")
1540                .with_value(WidgetValue::Count(self.lines.len())),
1541        ];
1542        let start = self.visible_start(inner.height);
1543
1544        for row in 0..inner.height {
1545            let line_idx = start + row as usize;
1546            if line_idx >= self.lines.len() {
1547                break;
1548            }
1549
1550            let row_area = Rect::new(inner.x, inner.y + row, inner.width, 1);
1551            regions.push(
1552                HitRegion::new(format!("{}:line:{line_idx}", region_id.as_ref()), row_area)
1553                    .with_role(WidgetRole::TranscriptRow)
1554                    .with_label(self.lines[line_idx].as_ref())
1555                    .with_action(WidgetAction::Select)
1556                    .with_cursor(MouseCursor::Text)
1557                    .with_row(line_idx)
1558                    .with_value(WidgetValue::LineNumber(line_idx + 1))
1559                    .with_z_index(1),
1560            );
1561        }
1562
1563        regions
1564    }
1565
1566    /// Returns the number of lines currently held.
1567    #[must_use]
1568    pub fn line_count(&self) -> usize {
1569        self.lines.len()
1570    }
1571
1572    /// Computes the first visible line index given the inner height.
1573    fn visible_start(&self, visible_height: u16) -> usize {
1574        let total = self.lines.len() as u16;
1575        if self.follow_tail || self.scroll_offset == 0 {
1576            let shown = visible_height.min(total);
1577            (total - shown) as usize
1578        } else {
1579            let max_top = total.saturating_sub(visible_height);
1580            (max_top.saturating_sub(self.scroll_offset)) as usize
1581        }
1582    }
1583}
1584
1585impl Default for StreamPanel<'_> {
1586    fn default() -> Self {
1587        Self::new()
1588    }
1589}
1590
1591impl Widget for StreamPanel<'_> {
1592    fn render(&self, buf: &mut Buffer, area: Rect) {
1593        let inner = self
1594            .block
1595            .as_ref()
1596            .map_or(area, |block| block_content_area(block, area));
1597        if let Some(block) = &self.block {
1598            block.render(buf, area);
1599        }
1600        if is_empty(inner) {
1601            return;
1602        }
1603
1604        let gutter_width = if self.show_line_numbers {
1605            let max_num = self.lines.len().max(1);
1606            let digits = format!("{max_num}").len() as u16;
1607            digits + 1
1608        } else {
1609            0
1610        };
1611
1612        let text_width = inner.width.saturating_sub(gutter_width);
1613        if text_width == 0 {
1614            return;
1615        }
1616
1617        let start = self.visible_start(inner.height);
1618        let right = inner.x.saturating_add(inner.width);
1619        let total = self.lines.len();
1620
1621        for row in 0..inner.height {
1622            let line_idx = start + row as usize;
1623            let y = inner.y + row;
1624
1625            if line_idx >= total {
1626                break;
1627            }
1628
1629            if self.show_line_numbers {
1630                let num_str = format!(
1631                    "{:>width$}",
1632                    line_idx + 1,
1633                    width = (gutter_width - 1) as usize
1634                );
1635                paint_text(
1636                    Rect::new(inner.x, y, gutter_width.saturating_sub(1), 1),
1637                    buf,
1638                    &num_str,
1639                    Style::default().fg(self.palette.shadow),
1640                );
1641                set_styled_char(
1642                    buf,
1643                    inner.x + gutter_width - 1,
1644                    y,
1645                    '│',
1646                    Style::default().fg(self.palette.shadow),
1647                );
1648            }
1649
1650            let line = &self.lines[line_idx];
1651            let text_chars: Vec<char> = line.chars().collect();
1652
1653            for col in 0..text_width {
1654                let x = inner.x + gutter_width + col;
1655                if x >= right {
1656                    break;
1657                }
1658                let ch = text_chars.get(col as usize).copied().unwrap_or(' ');
1659                let noise = field_noise(x, y, self.tick);
1660                let style = if ch == ' ' {
1661                    Style::default()
1662                } else if (noise + self.tick) % 31 == 0 {
1663                    Style::default()
1664                        .fg(self.palette.pulse)
1665                        .add_modifier(Modifier::BOLD)
1666                } else {
1667                    Style::default().fg(self.palette.high)
1668                };
1669                set_styled_char(buf, x, y, ch, style);
1670            }
1671        }
1672
1673        if !self.follow_tail && self.scroll_offset > 0 {
1674            let indicator_y = inner.y;
1675            let indicator_style = Style::default()
1676                .fg(self.palette.pulse)
1677                .add_modifier(Modifier::BOLD);
1678            if inner.width > 2 {
1679                set_styled_char(buf, right - 2, indicator_y, '▲', indicator_style);
1680            }
1681        }
1682    }
1683}
1684
1685// ---------------------------------------------------------------------------
1686// SplitPane — dynamic horizontal / vertical split with divider
1687// ---------------------------------------------------------------------------
1688
1689/// Direction for a split pane.
1690#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1691pub enum SplitDirection {
1692    Horizontal,
1693    Vertical,
1694}
1695
1696/// Computes two sub-areas from a parent area with a ratio and optional divider.
1697///
1698/// The ratio controls how much space the first pane gets (0.0..1.0).
1699/// The divider is rendered between the two panes and takes 1 cell.
1700pub struct SplitPane {
1701    ratio: f64,
1702    direction: SplitDirection,
1703    divider: Option<char>,
1704}
1705
1706impl SplitPane {
1707    /// Creates a horizontal split (top/bottom) with 50/50 ratio.
1708    #[must_use]
1709    pub fn horizontal() -> Self {
1710        Self {
1711            ratio: 0.5,
1712            direction: SplitDirection::Horizontal,
1713            divider: None,
1714        }
1715    }
1716
1717    /// Creates a vertical split (left/right) with 50/50 ratio.
1718    #[must_use]
1719    pub fn vertical() -> Self {
1720        Self {
1721            ratio: 0.5,
1722            direction: SplitDirection::Vertical,
1723            divider: None,
1724        }
1725    }
1726
1727    /// Sets the ratio for the first pane (clamped 0.0..1.0).
1728    #[must_use]
1729    pub fn ratio(mut self, ratio: f64) -> Self {
1730        self.ratio = ratio.clamp(0.0, 1.0);
1731        self
1732    }
1733
1734    /// Sets the divider character rendered between panes.
1735    #[must_use]
1736    pub fn divider(mut self, divider: char) -> Self {
1737        self.divider = Some(divider);
1738        self
1739    }
1740
1741    /// Splits the area and returns (pane_a, pane_b, divider_area).
1742    /// divider_area is zero-sized if no divider.
1743    pub fn split(&self, area: Rect) -> (Rect, Rect, Rect) {
1744        if is_empty(area) {
1745            return (Rect::ZERO, Rect::ZERO, Rect::ZERO);
1746        }
1747
1748        match self.direction {
1749            SplitDirection::Vertical => {
1750                let has_divider = self.divider.is_some() && area.width > 1;
1751                let available = if has_divider {
1752                    area.width.saturating_sub(1)
1753                } else {
1754                    area.width
1755                };
1756                let first_width = (f64::from(available) * self.ratio).round() as u16;
1757                let second_width = available.saturating_sub(first_width);
1758
1759                let a = Rect::new(area.x, area.y, first_width, area.height);
1760                let div = if has_divider {
1761                    Rect::new(area.x + first_width, area.y, 1, area.height)
1762                } else {
1763                    Rect::ZERO
1764                };
1765                let b_x = area.x + first_width + if has_divider { 1 } else { 0 };
1766                let b = Rect::new(b_x, area.y, second_width, area.height);
1767                (a, b, div)
1768            }
1769            SplitDirection::Horizontal => {
1770                let has_divider = self.divider.is_some() && area.height > 1;
1771                let available = if has_divider {
1772                    area.height.saturating_sub(1)
1773                } else {
1774                    area.height
1775                };
1776                let first_height = (f64::from(available) * self.ratio).round() as u16;
1777                let second_height = available.saturating_sub(first_height);
1778
1779                let a = Rect::new(area.x, area.y, area.width, first_height);
1780                let div = if has_divider {
1781                    Rect::new(area.x, area.y + first_height, area.width, 1)
1782                } else {
1783                    Rect::ZERO
1784                };
1785                let b_y = area.y + first_height + if has_divider { 1 } else { 0 };
1786                let b = Rect::new(area.x, b_y, area.width, second_height);
1787                (a, b, div)
1788            }
1789        }
1790    }
1791
1792    /// Renders the divider character into the divider area.
1793    pub fn render_divider(&self, buf: &mut Buffer, divider_area: Rect, palette: AislingPalette) {
1794        if is_empty(divider_area) {
1795            return;
1796        }
1797        let ch = self.divider.unwrap_or(' ');
1798        let style = Style::default().fg(palette.mid);
1799        for y in divider_area.y..divider_area.y.saturating_add(divider_area.height) {
1800            for x in divider_area.x..divider_area.x.saturating_add(divider_area.width) {
1801                set_styled_char(buf, x, y, ch, style);
1802            }
1803        }
1804    }
1805}
1806
1807// ---------------------------------------------------------------------------
1808// List — scrollable list with selection highlighting
1809// ---------------------------------------------------------------------------
1810
1811/// A scrollable list with item selection highlighting.
1812#[derive(Clone, Debug)]
1813pub struct List<'a> {
1814    items: Vec<Cow<'a, str>>,
1815    selected: Option<usize>,
1816    scroll_offset: u16,
1817    tick: u64,
1818    palette: AislingPalette,
1819    block: Option<Block<'a>>,
1820}
1821
1822impl PartialEq for List<'_> {
1823    fn eq(&self, other: &Self) -> bool {
1824        self.items == other.items
1825            && self.selected == other.selected
1826            && self.scroll_offset == other.scroll_offset
1827            && self.tick == other.tick
1828            && self.palette == other.palette
1829            && option_block_eq(self.block.as_ref(), other.block.as_ref())
1830    }
1831}
1832
1833impl<'a> List<'a> {
1834    /// Creates an empty list.
1835    #[must_use]
1836    pub fn new() -> Self {
1837        Self {
1838            items: Vec::new(),
1839            selected: None,
1840            scroll_offset: 0,
1841            tick: 0,
1842            palette: AislingPalette::cypherpunk(),
1843            block: None,
1844        }
1845    }
1846
1847    /// Adds an item to the list.
1848    #[must_use]
1849    pub fn item(mut self, item: impl Into<Cow<'a, str>>) -> Self {
1850        self.items.push(item.into());
1851        self
1852    }
1853
1854    /// Replaces all items.
1855    #[must_use]
1856    pub fn items<I, S>(mut self, items: I) -> Self
1857    where
1858        I: IntoIterator<Item = S>,
1859        S: Into<Cow<'a, str>>,
1860    {
1861        self.items = items.into_iter().map(Into::into).collect();
1862        self
1863    }
1864
1865    /// Sets the selected index.
1866    #[must_use]
1867    pub fn selected(mut self, index: Option<usize>) -> Self {
1868        self.selected = index;
1869        self
1870    }
1871
1872    /// Sets the scroll offset from the top.
1873    #[must_use]
1874    pub fn scroll_offset(mut self, offset: u16) -> Self {
1875        self.scroll_offset = offset;
1876        self
1877    }
1878
1879    /// Sets the animation tick.
1880    #[must_use]
1881    pub fn tick(mut self, tick: u64) -> Self {
1882        self.tick = tick;
1883        self
1884    }
1885
1886    /// Sets the color palette.
1887    #[must_use]
1888    pub fn palette(mut self, palette: AislingPalette) -> Self {
1889        self.palette = palette;
1890        self
1891    }
1892
1893    /// Adds a block around the list.
1894    #[must_use]
1895    pub fn block(mut self, block: Block<'a>) -> Self {
1896        self.block = Some(block);
1897        self
1898    }
1899
1900    /// Renders into a Scrin frame and registers hit regions for visible items.
1901    pub fn render_with_interaction(
1902        &self,
1903        frame: &mut Frame<'_>,
1904        id: impl Into<WidgetId>,
1905        area: Rect,
1906    ) {
1907        self.render(frame.buffer(), area);
1908        for region in self.hit_regions(id, area) {
1909            frame.register_hit_region(region);
1910        }
1911        frame.mark_dirty(area);
1912    }
1913
1914    /// Builds Scrin interaction metadata for the visible list items.
1915    #[must_use]
1916    pub fn hit_regions(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<HitRegion> {
1917        let region_id = id.into();
1918        let inner = self
1919            .block
1920            .as_ref()
1921            .map_or(area, |block| block_content_area(block, area));
1922        if is_empty(area) || is_empty(inner) {
1923            return Vec::new();
1924        }
1925
1926        let mut regions = vec![
1927            HitRegion::new(region_id.clone(), area)
1928                .with_role(WidgetRole::Region)
1929                .with_label("list")
1930                .with_value(WidgetValue::Count(self.items.len())),
1931        ];
1932        let group = SelectionGroup::new(format!("{}:items", region_id.as_ref()));
1933        let start = self.visible_start(inner.height);
1934
1935        for row in 0..inner.height {
1936            let idx = start + row as usize;
1937            if idx >= self.items.len() {
1938                break;
1939            }
1940
1941            let selected = self.selected == Some(idx);
1942            let row_area = Rect::new(inner.x, inner.y + row, inner.width, 1);
1943            regions.push(
1944                HitRegion::new(format!("{}:item:{idx}", region_id.as_ref()), row_area)
1945                    .with_role(WidgetRole::ListItem)
1946                    .with_label(self.items[idx].as_ref())
1947                    .with_action(WidgetAction::Focus)
1948                    .with_cursor(MouseCursor::Pointer)
1949                    .with_row(idx)
1950                    .with_selection_group(group.clone())
1951                    .with_state(WidgetState::default().selected(selected))
1952                    .with_z_index(1),
1953            );
1954        }
1955
1956        regions
1957    }
1958
1959    /// Returns the number of items.
1960    #[must_use]
1961    pub fn item_count(&self) -> usize {
1962        self.items.len()
1963    }
1964
1965    fn visible_start(&self, visible_height: u16) -> usize {
1966        let total = self.items.len() as u16;
1967        if let Some(sel) = self.selected {
1968            let sel = sel as u16;
1969            if sel < self.scroll_offset {
1970                return sel as usize;
1971            }
1972            if sel >= self.scroll_offset + visible_height {
1973                return (sel + 1 - visible_height) as usize;
1974            }
1975            return self.scroll_offset as usize;
1976        }
1977        let max_top = total.saturating_sub(visible_height);
1978        (self.scroll_offset.min(max_top)) as usize
1979    }
1980}
1981
1982impl Default for List<'_> {
1983    fn default() -> Self {
1984        Self::new()
1985    }
1986}
1987
1988impl Widget for List<'_> {
1989    fn render(&self, buf: &mut Buffer, area: Rect) {
1990        let inner = self
1991            .block
1992            .as_ref()
1993            .map_or(area, |block| block_content_area(block, area));
1994        if let Some(block) = &self.block {
1995            block.render(buf, area);
1996        }
1997        if is_empty(inner) {
1998            return;
1999        }
2000
2001        let start = self.visible_start(inner.height);
2002        let indicator_width = 2u16;
2003        let text_width = inner.width.saturating_sub(indicator_width);
2004
2005        for row in 0..inner.height {
2006            let idx = start + row as usize;
2007            let y = inner.y + row;
2008
2009            if idx >= self.items.len() {
2010                break;
2011            }
2012
2013            let is_selected = self.selected == Some(idx);
2014
2015            let indicator = if is_selected { "▸ " } else { "  " };
2016            let indicator_style = if is_selected {
2017                Style::default()
2018                    .fg(self.palette.pulse)
2019                    .add_modifier(Modifier::BOLD)
2020            } else {
2021                Style::default().fg(self.palette.shadow)
2022            };
2023            paint_text(
2024                Rect::new(inner.x, y, indicator_width, 1),
2025                buf,
2026                indicator,
2027                indicator_style,
2028            );
2029
2030            let item = &self.items[idx];
2031            let item_chars: Vec<char> = item.chars().collect();
2032
2033            for col in 0..text_width {
2034                let x = inner.x + indicator_width + col;
2035                let ch = item_chars.get(col as usize).copied().unwrap_or(' ');
2036                let style = if is_selected {
2037                    if ch == ' ' {
2038                        Style::default().bg(self.palette.shadow)
2039                    } else {
2040                        Style::default()
2041                            .fg(self.palette.high)
2042                            .bg(self.palette.shadow)
2043                            .add_modifier(Modifier::BOLD)
2044                    }
2045                } else if ch == ' ' {
2046                    Style::default()
2047                } else {
2048                    Style::default().fg(self.palette.high)
2049                };
2050                set_styled_char(buf, x, y, ch, style);
2051            }
2052        }
2053    }
2054}
2055
2056// ---------------------------------------------------------------------------
2057// TabBar — horizontal tab navigation
2058// ---------------------------------------------------------------------------
2059
2060/// A horizontal tab bar with a selected tab indicator.
2061#[derive(Clone, Debug)]
2062pub struct TabBar<'a> {
2063    tabs: Vec<Cow<'a, str>>,
2064    selected: usize,
2065    tick: u64,
2066    palette: AislingPalette,
2067    block: Option<Block<'a>>,
2068}
2069
2070impl PartialEq for TabBar<'_> {
2071    fn eq(&self, other: &Self) -> bool {
2072        self.tabs == other.tabs
2073            && self.selected == other.selected
2074            && self.tick == other.tick
2075            && self.palette == other.palette
2076            && option_block_eq(self.block.as_ref(), other.block.as_ref())
2077    }
2078}
2079
2080impl<'a> TabBar<'a> {
2081    /// Creates a tab bar with the given tab titles.
2082    #[must_use]
2083    pub fn new<I, S>(tabs: I) -> Self
2084    where
2085        I: IntoIterator<Item = S>,
2086        S: Into<Cow<'a, str>>,
2087    {
2088        Self {
2089            tabs: tabs.into_iter().map(Into::into).collect(),
2090            selected: 0,
2091            tick: 0,
2092            palette: AislingPalette::cypherpunk(),
2093            block: None,
2094        }
2095    }
2096
2097    /// Sets the selected tab index.
2098    #[must_use]
2099    pub fn selected(mut self, index: usize) -> Self {
2100        self.selected = index;
2101        self
2102    }
2103
2104    /// Sets the animation tick.
2105    #[must_use]
2106    pub fn tick(mut self, tick: u64) -> Self {
2107        self.tick = tick;
2108        self
2109    }
2110
2111    /// Sets the color palette.
2112    #[must_use]
2113    pub fn palette(mut self, palette: AislingPalette) -> Self {
2114        self.palette = palette;
2115        self
2116    }
2117
2118    /// Adds a block around the tab bar.
2119    #[must_use]
2120    pub fn block(mut self, block: Block<'a>) -> Self {
2121        self.block = Some(block);
2122        self
2123    }
2124
2125    /// Renders into a Scrin frame and registers hit regions for each visible tab.
2126    pub fn render_with_interaction(
2127        &self,
2128        frame: &mut Frame<'_>,
2129        id: impl Into<WidgetId>,
2130        area: Rect,
2131    ) {
2132        self.render(frame.buffer(), area);
2133        for region in self.hit_regions(id, area) {
2134            frame.register_hit_region(region);
2135        }
2136        frame.mark_dirty(area);
2137    }
2138
2139    /// Builds Scrin interaction metadata for the visible tabs.
2140    #[must_use]
2141    pub fn hit_regions(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<HitRegion> {
2142        let region_id = id.into();
2143        let inner = self
2144            .block
2145            .as_ref()
2146            .map_or(area, |block| block_content_area(block, area));
2147        if is_empty(area) || is_empty(inner) {
2148            return Vec::new();
2149        }
2150
2151        let mut regions = vec![
2152            HitRegion::new(region_id.clone(), area)
2153                .with_role(WidgetRole::Region)
2154                .with_label("tabs")
2155                .with_value(WidgetValue::Count(self.tabs.len())),
2156        ];
2157        let mut x = inner.x;
2158        let right = inner.x.saturating_add(inner.width);
2159
2160        for (idx, tab) in self.tabs.iter().enumerate() {
2161            if x >= right {
2162                break;
2163            }
2164
2165            let label_width = tab.chars().count() as u16;
2166            let tab_width = label_width.saturating_add(4).min(right - x);
2167            if tab_width == 0 {
2168                break;
2169            }
2170
2171            regions.push(
2172                HitRegion::new(
2173                    format!("{}:tab:{idx}", region_id.as_ref()),
2174                    Rect::new(x, inner.y, tab_width, 1),
2175                )
2176                .with_role(WidgetRole::Tab)
2177                .with_label(tab.as_ref())
2178                .with_action(WidgetAction::Focus)
2179                .with_cursor(MouseCursor::Pointer)
2180                .with_row(idx)
2181                .with_shortcut(format!("{}", idx + 1))
2182                .with_state(WidgetState::default().selected(idx == self.selected))
2183                .with_z_index(1),
2184            );
2185
2186            x = x.saturating_add(tab_width);
2187        }
2188
2189        regions
2190    }
2191
2192    /// Returns the number of tabs.
2193    #[must_use]
2194    pub fn tab_count(&self) -> usize {
2195        self.tabs.len()
2196    }
2197}
2198
2199impl Widget for TabBar<'_> {
2200    fn render(&self, buf: &mut Buffer, area: Rect) {
2201        let inner = self
2202            .block
2203            .as_ref()
2204            .map_or(area, |block| block_content_area(block, area));
2205        if let Some(block) = &self.block {
2206            block.render(buf, area);
2207        }
2208        if is_empty(inner) {
2209            return;
2210        }
2211
2212        let mut x = inner.x;
2213        let right = inner.x.saturating_add(inner.width);
2214
2215        for (i, tab) in self.tabs.iter().enumerate() {
2216            if x >= right {
2217                break;
2218            }
2219
2220            let is_selected = i == self.selected;
2221            let label: Vec<char> = tab.chars().collect();
2222            let padding = 2u16;
2223            let tab_width = (label.len() as u16 + padding * 2).min(right - x);
2224
2225            if is_selected {
2226                set_styled_char(
2227                    buf,
2228                    x,
2229                    inner.y,
2230                    '㎍',
2231                    Style::default()
2232                        .fg(self.palette.pulse)
2233                        .add_modifier(Modifier::BOLD),
2234                );
2235            } else {
2236                set_styled_char(buf, x, inner.y, ' ', Style::default());
2237            }
2238
2239            for col in 0..tab_width {
2240                let cx = x + col;
2241                if cx >= right {
2242                    break;
2243                }
2244
2245                let char_idx = col.saturating_sub(padding) as usize;
2246                let ch = if col < padding || col >= tab_width - padding {
2247                    ' '
2248                } else if char_idx < label.len() {
2249                    label[char_idx]
2250                } else {
2251                    ' '
2252                };
2253
2254                let style = if is_selected {
2255                    Style::default()
2256                        .fg(self.palette.high)
2257                        .add_modifier(Modifier::BOLD)
2258                } else {
2259                    Style::default().fg(self.palette.mid)
2260                };
2261                set_styled_char(buf, cx, inner.y, ch, style);
2262            }
2263
2264            if is_selected {
2265                let bottom = inner.y + inner.height.saturating_sub(1);
2266                for col in 0..tab_width {
2267                    let cx = x + col;
2268                    if cx >= right {
2269                        break;
2270                    }
2271                    set_styled_char(
2272                        buf,
2273                        cx,
2274                        bottom,
2275                        '─',
2276                        Style::default().fg(self.palette.pulse),
2277                    );
2278                }
2279            }
2280
2281            x += tab_width;
2282        }
2283    }
2284}
2285
2286// ---------------------------------------------------------------------------
2287// Table — data table with headers, rows, and selection
2288// ---------------------------------------------------------------------------
2289
2290/// A data table with column headers, rows, and optional row selection.
2291#[derive(Clone, Debug)]
2292pub struct Table<'a> {
2293    headers: Vec<Cow<'a, str>>,
2294    rows: Vec<Vec<Cow<'a, str>>>,
2295    widths: Option<Vec<u16>>,
2296    selected: Option<usize>,
2297    scroll_offset: u16,
2298    tick: u64,
2299    palette: AislingPalette,
2300    block: Option<Block<'a>>,
2301}
2302
2303impl PartialEq for Table<'_> {
2304    fn eq(&self, other: &Self) -> bool {
2305        self.headers == other.headers
2306            && self.rows == other.rows
2307            && self.widths == other.widths
2308            && self.selected == other.selected
2309            && self.scroll_offset == other.scroll_offset
2310            && self.tick == other.tick
2311            && self.palette == other.palette
2312            && option_block_eq(self.block.as_ref(), other.block.as_ref())
2313    }
2314}
2315
2316impl<'a> Table<'a> {
2317    /// Creates a table with column headers.
2318    #[must_use]
2319    pub fn new<I, S>(headers: I) -> Self
2320    where
2321        I: IntoIterator<Item = S>,
2322        S: Into<Cow<'a, str>>,
2323    {
2324        Self {
2325            headers: headers.into_iter().map(Into::into).collect(),
2326            rows: Vec::new(),
2327            widths: None,
2328            selected: None,
2329            scroll_offset: 0,
2330            tick: 0,
2331            palette: AislingPalette::cypherpunk(),
2332            block: None,
2333        }
2334    }
2335
2336    /// Adds a row to the table.
2337    #[must_use]
2338    pub fn row<I, S>(mut self, row: I) -> Self
2339    where
2340        I: IntoIterator<Item = S>,
2341        S: Into<Cow<'a, str>>,
2342    {
2343        self.rows.push(row.into_iter().map(Into::into).collect());
2344        self
2345    }
2346
2347    /// Replaces all rows.
2348    #[must_use]
2349    pub fn rows<I, R, S>(mut self, rows: I) -> Self
2350    where
2351        I: IntoIterator<Item = R>,
2352        R: IntoIterator<Item = S>,
2353        S: Into<Cow<'a, str>>,
2354    {
2355        self.rows = rows
2356            .into_iter()
2357            .map(|r| r.into_iter().map(Into::into).collect())
2358            .collect();
2359        self
2360    }
2361
2362    /// Sets explicit column widths. If not set, widths are auto-distributed.
2363    #[must_use]
2364    pub fn widths(mut self, widths: Vec<u16>) -> Self {
2365        self.widths = Some(widths);
2366        self
2367    }
2368
2369    /// Sets the selected row index.
2370    #[must_use]
2371    pub fn selected(mut self, index: Option<usize>) -> Self {
2372        self.selected = index;
2373        self
2374    }
2375
2376    /// Sets the scroll offset from the top.
2377    #[must_use]
2378    pub fn scroll_offset(mut self, offset: u16) -> Self {
2379        self.scroll_offset = offset;
2380        self
2381    }
2382
2383    /// Sets the animation tick.
2384    #[must_use]
2385    pub fn tick(mut self, tick: u64) -> Self {
2386        self.tick = tick;
2387        self
2388    }
2389
2390    /// Sets the color palette.
2391    #[must_use]
2392    pub fn palette(mut self, palette: AislingPalette) -> Self {
2393        self.palette = palette;
2394        self
2395    }
2396
2397    /// Adds a block around the table.
2398    #[must_use]
2399    pub fn block(mut self, block: Block<'a>) -> Self {
2400        self.block = Some(block);
2401        self
2402    }
2403
2404    /// Renders into a Scrin frame and registers hit regions for visible rows.
2405    pub fn render_with_interaction(
2406        &self,
2407        frame: &mut Frame<'_>,
2408        id: impl Into<WidgetId>,
2409        area: Rect,
2410    ) {
2411        self.render(frame.buffer(), area);
2412        for region in self.hit_regions(id, area) {
2413            frame.register_hit_region(region);
2414        }
2415        frame.mark_dirty(area);
2416    }
2417
2418    /// Builds Scrin interaction metadata for the visible table rows.
2419    #[must_use]
2420    pub fn hit_regions(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<HitRegion> {
2421        let region_id = id.into();
2422        let inner = self
2423            .block
2424            .as_ref()
2425            .map_or(area, |block| block_content_area(block, area));
2426        if is_empty(area) || is_empty(inner) {
2427            return Vec::new();
2428        }
2429
2430        let mut regions = vec![
2431            HitRegion::new(region_id.clone(), area)
2432                .with_role(WidgetRole::Region)
2433                .with_label("table")
2434                .with_value(WidgetValue::Count(self.rows.len())),
2435        ];
2436        if self.headers.is_empty() || inner.height < 3 {
2437            return regions;
2438        }
2439
2440        let visible_rows = inner.height.saturating_sub(2);
2441        let start = self
2442            .scroll_offset
2443            .min((self.rows.len() as u16).saturating_sub(visible_rows.min(self.rows.len() as u16)));
2444
2445        for row_offset in 0..visible_rows {
2446            let row_idx = start as usize + row_offset as usize;
2447            if row_idx >= self.rows.len() {
2448                break;
2449            }
2450
2451            let y = inner.y + 2 + row_offset;
2452            let selected = self.selected == Some(row_idx);
2453            let label = self.rows[row_idx]
2454                .iter()
2455                .map(Cow::as_ref)
2456                .collect::<Vec<_>>()
2457                .join(" | ");
2458            regions.push(
2459                HitRegion::new(
2460                    format!("{}:row:{row_idx}", region_id.as_ref()),
2461                    Rect::new(inner.x, y, inner.width, 1),
2462                )
2463                .with_role(WidgetRole::ModelRow)
2464                .with_label(label)
2465                .with_action(WidgetAction::Focus)
2466                .with_cursor(MouseCursor::Pointer)
2467                .with_row(row_idx)
2468                .with_state(WidgetState::default().selected(selected))
2469                .with_value(WidgetValue::Count(self.rows[row_idx].len()))
2470                .with_z_index(1),
2471            );
2472        }
2473
2474        regions
2475    }
2476
2477    /// Returns the number of rows.
2478    #[must_use]
2479    pub fn row_count(&self) -> usize {
2480        self.rows.len()
2481    }
2482
2483    fn compute_widths(&self, total_width: u16) -> Vec<u16> {
2484        if let Some(ref w) = self.widths {
2485            return w.clone();
2486        }
2487        let cols = self.headers.len().max(1) as u16;
2488        let per_col = total_width / cols;
2489        let mut widths = vec![per_col; cols as usize];
2490        let remainder = total_width.saturating_sub(per_col * cols);
2491        for w in widths.iter_mut().take(remainder as usize) {
2492            *w += 1;
2493        }
2494        widths
2495    }
2496}
2497
2498impl Widget for Table<'_> {
2499    fn render(&self, buf: &mut Buffer, area: Rect) {
2500        let inner = self
2501            .block
2502            .as_ref()
2503            .map_or(area, |block| block_content_area(block, area));
2504        if let Some(block) = &self.block {
2505            block.render(buf, area);
2506        }
2507        if is_empty(inner) || self.headers.is_empty() {
2508            return;
2509        }
2510
2511        let col_widths = self.compute_widths(inner.width);
2512        let header_height = 1u16;
2513        let divider_height = 1u16;
2514        let data_start_y = inner.y + header_height + divider_height;
2515        let visible_rows = inner.height.saturating_sub(header_height + divider_height);
2516
2517        for (col_idx, header) in self.headers.iter().enumerate() {
2518            if col_idx >= col_widths.len() {
2519                break;
2520            }
2521            let col_x = inner.x + col_widths[..col_idx].iter().sum::<u16>();
2522            let w = col_widths[col_idx];
2523            paint_text(
2524                Rect::new(col_x, inner.y, w, 1),
2525                buf,
2526                header.as_ref(),
2527                Style::default()
2528                    .fg(self.palette.pulse)
2529                    .add_modifier(Modifier::BOLD),
2530            );
2531        }
2532
2533        let div_y = inner.y + header_height;
2534        for col_idx in 0..self.headers.len().min(col_widths.len()) {
2535            let col_x = inner.x + col_widths[..col_idx].iter().sum::<u16>();
2536            let w = col_widths[col_idx];
2537            for dx in 0..w {
2538                set_styled_char(
2539                    buf,
2540                    col_x + dx,
2541                    div_y,
2542                    '─',
2543                    Style::default().fg(self.palette.shadow),
2544                );
2545            }
2546        }
2547
2548        let total = self.rows.len() as u16;
2549        let start = self
2550            .scroll_offset
2551            .min(total.saturating_sub(visible_rows.min(total)));
2552
2553        for row_offset in 0..visible_rows {
2554            let row_idx = start as usize + row_offset as usize;
2555            let y = data_start_y + row_offset;
2556
2557            if row_idx >= self.rows.len() {
2558                break;
2559            }
2560
2561            let is_selected = self.selected == Some(row_idx);
2562            let row = &self.rows[row_idx];
2563
2564            for (col_idx, cell) in row.iter().enumerate() {
2565                if col_idx >= col_widths.len() {
2566                    break;
2567                }
2568                let col_x = inner.x + col_widths[..col_idx].iter().sum::<u16>();
2569                let w = col_widths[col_idx];
2570
2571                let cell_chars: Vec<char> = cell.chars().collect();
2572                for dx in 0..w {
2573                    let ch = cell_chars.get(dx as usize).copied().unwrap_or(' ');
2574                    let style = if is_selected {
2575                        Style::default()
2576                            .fg(self.palette.high)
2577                            .bg(self.palette.shadow)
2578                            .add_modifier(Modifier::BOLD)
2579                    } else {
2580                        Style::default().fg(self.palette.high)
2581                    };
2582                    set_styled_char(buf, col_x + dx, y, ch, style);
2583                }
2584            }
2585        }
2586    }
2587}
2588
2589// ---------------------------------------------------------------------------
2590// Sparkline — inline mini bar chart
2591// ---------------------------------------------------------------------------
2592
2593/// An inline sparkline / mini bar chart.
2594#[derive(Clone, Debug)]
2595pub struct Sparkline<'a> {
2596    data: Vec<u16>,
2597    max_value: Option<u16>,
2598    palette: AislingPalette,
2599    block: Option<Block<'a>>,
2600}
2601
2602impl PartialEq for Sparkline<'_> {
2603    fn eq(&self, other: &Self) -> bool {
2604        self.data == other.data
2605            && self.max_value == other.max_value
2606            && self.palette == other.palette
2607            && option_block_eq(self.block.as_ref(), other.block.as_ref())
2608    }
2609}
2610
2611impl<'a> Sparkline<'a> {
2612    /// Creates a sparkline with the given data points.
2613    #[must_use]
2614    pub fn new(data: Vec<u16>) -> Self {
2615        Self {
2616            data,
2617            max_value: None,
2618            palette: AislingPalette::phosphor(),
2619            block: None,
2620        }
2621    }
2622
2623    /// Sets an explicit maximum value for scaling. If not set, uses the data max.
2624    #[must_use]
2625    pub fn max_value(mut self, max: u16) -> Self {
2626        self.max_value = Some(max);
2627        self
2628    }
2629
2630    /// Sets the color palette.
2631    #[must_use]
2632    pub fn palette(mut self, palette: AislingPalette) -> Self {
2633        self.palette = palette;
2634        self
2635    }
2636
2637    /// Adds a block around the sparkline.
2638    #[must_use]
2639    pub fn block(mut self, block: Block<'a>) -> Self {
2640        self.block = Some(block);
2641        self
2642    }
2643}
2644
2645impl Widget for Sparkline<'_> {
2646    fn render(&self, buf: &mut Buffer, area: Rect) {
2647        let inner = self
2648            .block
2649            .as_ref()
2650            .map_or(area, |block| block_content_area(block, area));
2651        if let Some(block) = &self.block {
2652            block.render(buf, area);
2653        }
2654        if is_empty(inner) || self.data.is_empty() {
2655            return;
2656        }
2657
2658        let max = self
2659            .max_value
2660            .unwrap_or_else(|| self.data.iter().copied().max().unwrap_or(1))
2661            .max(1);
2662        let bottom = inner.y.saturating_add(inner.height);
2663
2664        for col in 0..inner.width {
2665            let data_idx = (col as usize * self.data.len()) / usize::from(inner.width);
2666            let value = self.data.get(data_idx).copied().unwrap_or(0);
2667            let bar_height =
2668                ((f64::from(value) / f64::from(max)) * f64::from(inner.height)).round() as u16;
2669            let bar_y = bottom.saturating_sub(bar_height);
2670
2671            for y in bar_y..bottom {
2672                let noise = field_noise(inner.x + col, y, 0);
2673                let style = Style::default()
2674                    .fg(self.palette.lane(noise))
2675                    .add_modifier(Modifier::BOLD);
2676                set_styled_char(buf, inner.x + col, y, '█', style);
2677            }
2678
2679            for y in inner.y..bar_y {
2680                set_styled_char(
2681                    buf,
2682                    inner.x + col,
2683                    y,
2684                    '·',
2685                    Style::default().fg(self.palette.shadow),
2686                );
2687            }
2688        }
2689    }
2690}
2691
2692// ---------------------------------------------------------------------------
2693// Gauge — simple utilitarian progress bar
2694// ---------------------------------------------------------------------------
2695
2696/// A simple utilitarian progress bar (no animation, clean display).
2697#[derive(Clone, Debug)]
2698pub struct Gauge<'a> {
2699    ratio: f64,
2700    label: Option<Cow<'a, str>>,
2701    palette: AislingPalette,
2702    block: Option<Block<'a>>,
2703}
2704
2705impl PartialEq for Gauge<'_> {
2706    fn eq(&self, other: &Self) -> bool {
2707        self.ratio == other.ratio
2708            && self.label == other.label
2709            && self.palette == other.palette
2710            && option_block_eq(self.block.as_ref(), other.block.as_ref())
2711    }
2712}
2713
2714impl<'a> Gauge<'a> {
2715    /// Creates a gauge with a ratio clamped to 0.0..=1.0.
2716    #[must_use]
2717    pub fn new(ratio: f64) -> Self {
2718        Self {
2719            ratio: ratio.clamp(0.0, 1.0),
2720            label: None,
2721            palette: AislingPalette::cypherpunk(),
2722            block: None,
2723        }
2724    }
2725
2726    /// Returns the clamped ratio.
2727    #[must_use]
2728    pub fn ratio(&self) -> f64 {
2729        self.ratio
2730    }
2731
2732    /// Sets the centered label.
2733    #[must_use]
2734    pub fn label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
2735        self.label = Some(label.into());
2736        self
2737    }
2738
2739    /// Sets the color palette.
2740    #[must_use]
2741    pub fn palette(mut self, palette: AislingPalette) -> Self {
2742        self.palette = palette;
2743        self
2744    }
2745
2746    /// Adds a block around the gauge.
2747    #[must_use]
2748    pub fn block(mut self, block: Block<'a>) -> Self {
2749        self.block = Some(block);
2750        self
2751    }
2752}
2753
2754impl Widget for Gauge<'_> {
2755    fn render(&self, buf: &mut Buffer, area: Rect) {
2756        let inner = self
2757            .block
2758            .as_ref()
2759            .map_or(area, |block| block_content_area(block, area));
2760        if let Some(block) = &self.block {
2761            block.render(buf, area);
2762        }
2763        if is_empty(inner) {
2764            return;
2765        }
2766
2767        let right = inner.x.saturating_add(inner.width);
2768        let bottom = inner.y.saturating_add(inner.height);
2769        let filled = (f64::from(inner.width) * self.ratio).round() as u16;
2770
2771        for y in inner.y..bottom {
2772            for x in inner.x..right {
2773                let offset = x.saturating_sub(inner.x);
2774                if offset < filled {
2775                    set_styled_char(
2776                        buf,
2777                        x,
2778                        y,
2779                        '█',
2780                        Style::default()
2781                            .fg(self.palette.mid)
2782                            .add_modifier(Modifier::BOLD),
2783                    );
2784                } else {
2785                    set_styled_char(buf, x, y, '░', Style::default().fg(self.palette.shadow));
2786                }
2787            }
2788        }
2789
2790        if let Some(label) = &self.label {
2791            let row = inner.y + inner.height / 2;
2792            let label_width = label.chars().count().min(usize::from(inner.width)) as u16;
2793            let start = inner.x + inner.width.saturating_sub(label_width) / 2;
2794            paint_text(
2795                Rect::new(start, row, label_width, 1),
2796                buf,
2797                label.as_ref(),
2798                Style::default()
2799                    .fg(self.palette.high)
2800                    .add_modifier(Modifier::BOLD),
2801            );
2802        }
2803    }
2804}
2805
2806// ---------------------------------------------------------------------------
2807// Paragraph — scrollable text block with word wrap
2808// ---------------------------------------------------------------------------
2809
2810/// A scrollable text paragraph with word wrapping.
2811#[derive(Clone, Debug)]
2812pub struct Paragraph<'a> {
2813    text: Cow<'a, str>,
2814    scroll_offset: u16,
2815    palette: AislingPalette,
2816    block: Option<Block<'a>>,
2817}
2818
2819impl PartialEq for Paragraph<'_> {
2820    fn eq(&self, other: &Self) -> bool {
2821        self.text == other.text
2822            && self.scroll_offset == other.scroll_offset
2823            && self.palette == other.palette
2824            && option_block_eq(self.block.as_ref(), other.block.as_ref())
2825    }
2826}
2827
2828impl<'a> Paragraph<'a> {
2829    /// Creates a paragraph with the given text.
2830    #[must_use]
2831    pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
2832        Self {
2833            text: text.into(),
2834            scroll_offset: 0,
2835            palette: AislingPalette::cypherpunk(),
2836            block: None,
2837        }
2838    }
2839
2840    /// Sets the scroll offset from the top.
2841    #[must_use]
2842    pub fn scroll_offset(mut self, offset: u16) -> Self {
2843        self.scroll_offset = offset;
2844        self
2845    }
2846
2847    /// Sets the color palette.
2848    #[must_use]
2849    pub fn palette(mut self, palette: AislingPalette) -> Self {
2850        self.palette = palette;
2851        self
2852    }
2853
2854    /// Adds a block around the paragraph.
2855    #[must_use]
2856    pub fn block(mut self, block: Block<'a>) -> Self {
2857        self.block = Some(block);
2858        self
2859    }
2860
2861    fn wrap_lines(&self, width: u16) -> Vec<Cow<'_, str>> {
2862        if width == 0 {
2863            return Vec::new();
2864        }
2865        let mut result = Vec::new();
2866        for raw_line in self.text.lines() {
2867            if raw_line.is_empty() {
2868                result.push(Cow::Borrowed(""));
2869                continue;
2870            }
2871            let mut remaining = raw_line;
2872            while !remaining.is_empty() {
2873                let w = usize::from(width);
2874                if remaining.len() <= w {
2875                    result.push(Cow::Borrowed(remaining));
2876                    break;
2877                }
2878                let break_at = remaining[..w].rfind(' ').map(|p| p + 1).unwrap_or(w);
2879                result.push(Cow::Borrowed(&remaining[..break_at]));
2880                remaining = &remaining[break_at..];
2881            }
2882        }
2883        result
2884    }
2885}
2886
2887impl Widget for Paragraph<'_> {
2888    fn render(&self, buf: &mut Buffer, area: Rect) {
2889        let inner = self
2890            .block
2891            .as_ref()
2892            .map_or(area, |block| block_content_area(block, area));
2893        if let Some(block) = &self.block {
2894            block.render(buf, area);
2895        }
2896        if is_empty(inner) {
2897            return;
2898        }
2899
2900        let wrapped = self.wrap_lines(inner.width);
2901        let total = wrapped.len() as u16;
2902        let start = self
2903            .scroll_offset
2904            .min(total.saturating_sub(inner.height.min(total)));
2905
2906        for row in 0..inner.height {
2907            let line_idx = start as usize + row as usize;
2908            let y = inner.y + row;
2909
2910            if line_idx >= wrapped.len() {
2911                break;
2912            }
2913
2914            let line = &wrapped[line_idx];
2915            let chars: Vec<char> = line.chars().collect();
2916
2917            for col in 0..inner.width {
2918                let ch = chars.get(col as usize).copied().unwrap_or(' ');
2919                let style = if ch == ' ' {
2920                    Style::default()
2921                } else {
2922                    Style::default().fg(self.palette.high)
2923                };
2924                set_styled_char(buf, inner.x + col, y, ch, style);
2925            }
2926        }
2927    }
2928}
2929
2930// ---------------------------------------------------------------------------
2931// StatusBar — multi-section horizontal status bar
2932// ---------------------------------------------------------------------------
2933
2934/// Alignment for a status bar section.
2935#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2936pub enum Align {
2937    Left,
2938    Center,
2939    Right,
2940}
2941
2942/// A section within a status bar.
2943#[derive(Clone, Debug)]
2944pub struct StatusSection<'a> {
2945    text: Cow<'a, str>,
2946    align: Align,
2947}
2948
2949/// A multi-section horizontal status bar.
2950#[derive(Clone, Debug)]
2951pub struct StatusBar<'a> {
2952    sections: Vec<StatusSection<'a>>,
2953    palette: AislingPalette,
2954}
2955
2956impl PartialEq for StatusBar<'_> {
2957    fn eq(&self, other: &Self) -> bool {
2958        self.sections.len() == other.sections.len()
2959            && self
2960                .sections
2961                .iter()
2962                .zip(other.sections.iter())
2963                .all(|(a, b)| a.text == b.text && a.align == b.align)
2964            && self.palette == other.palette
2965    }
2966}
2967
2968impl<'a> StatusBar<'a> {
2969    /// Creates an empty status bar.
2970    #[must_use]
2971    pub fn new() -> Self {
2972        Self {
2973            sections: Vec::new(),
2974            palette: AislingPalette::cypherpunk(),
2975        }
2976    }
2977
2978    /// Adds a left-aligned section.
2979    #[must_use]
2980    pub fn left(mut self, text: impl Into<Cow<'a, str>>) -> Self {
2981        self.sections.push(StatusSection {
2982            text: text.into(),
2983            align: Align::Left,
2984        });
2985        self
2986    }
2987
2988    /// Adds a center-aligned section.
2989    #[must_use]
2990    pub fn center(mut self, text: impl Into<Cow<'a, str>>) -> Self {
2991        self.sections.push(StatusSection {
2992            text: text.into(),
2993            align: Align::Center,
2994        });
2995        self
2996    }
2997
2998    /// Adds a right-aligned section.
2999    #[must_use]
3000    pub fn right(mut self, text: impl Into<Cow<'a, str>>) -> Self {
3001        self.sections.push(StatusSection {
3002            text: text.into(),
3003            align: Align::Right,
3004        });
3005        self
3006    }
3007
3008    /// Sets the color palette.
3009    #[must_use]
3010    pub fn palette(mut self, palette: AislingPalette) -> Self {
3011        self.palette = palette;
3012        self
3013    }
3014}
3015
3016impl Default for StatusBar<'_> {
3017    fn default() -> Self {
3018        Self::new()
3019    }
3020}
3021
3022impl Widget for StatusBar<'_> {
3023    fn render(&self, buf: &mut Buffer, area: Rect) {
3024        if is_empty(area) || self.sections.is_empty() {
3025            return;
3026        }
3027
3028        let bg_style = Style::default()
3029            .fg(self.palette.high)
3030            .bg(self.palette.shadow);
3031
3032        for x in area.x..area.x.saturating_add(area.width) {
3033            for y in area.y..area.y.saturating_add(area.height) {
3034                set_styled_char(buf, x, y, ' ', bg_style);
3035            }
3036        }
3037
3038        let left_sections: Vec<_> = self
3039            .sections
3040            .iter()
3041            .filter(|s| s.align == Align::Left)
3042            .collect();
3043        let center_sections: Vec<_> = self
3044            .sections
3045            .iter()
3046            .filter(|s| s.align == Align::Center)
3047            .collect();
3048        let right_sections: Vec<_> = self
3049            .sections
3050            .iter()
3051            .filter(|s| s.align == Align::Right)
3052            .collect();
3053
3054        let mut x = area.x;
3055
3056        for section in &left_sections {
3057            let text: Vec<char> = section.text.chars().collect();
3058            let max_len = text
3059                .len()
3060                .min(usize::from(area.width.saturating_sub(x - area.x)));
3061            for (i, &ch) in text.iter().take(max_len).enumerate() {
3062                set_styled_char(
3063                    buf,
3064                    x + i as u16,
3065                    area.y,
3066                    ch,
3067                    Style::default()
3068                        .fg(self.palette.high)
3069                        .add_modifier(Modifier::BOLD),
3070                );
3071            }
3072            x += max_len as u16;
3073        }
3074
3075        for section in &center_sections {
3076            let text: Vec<char> = section.text.chars().collect();
3077            let available = area.width.saturating_sub(x - area.x);
3078            let start_offset = available.saturating_sub(text.len() as u16) / 2;
3079            x += start_offset;
3080            for (i, &ch) in text.iter().take(usize::from(available)).enumerate() {
3081                set_styled_char(
3082                    buf,
3083                    x + i as u16,
3084                    area.y,
3085                    ch,
3086                    Style::default()
3087                        .fg(self.palette.high)
3088                        .add_modifier(Modifier::BOLD),
3089                );
3090            }
3091            x += text.len() as u16;
3092        }
3093
3094        let right_x = area.x + area.width;
3095        let mut render_x = right_x;
3096        for section in right_sections.iter().rev() {
3097            let text: Vec<char> = section.text.chars().collect();
3098            render_x = render_x.saturating_sub(text.len() as u16);
3099            for (i, &ch) in text.iter().enumerate() {
3100                if render_x + (i as u16) >= area.x && render_x + (i as u16) < right_x {
3101                    set_styled_char(
3102                        buf,
3103                        render_x + i as u16,
3104                        area.y,
3105                        ch,
3106                        Style::default()
3107                            .fg(self.palette.high)
3108                            .add_modifier(Modifier::BOLD),
3109                    );
3110                }
3111            }
3112        }
3113    }
3114}
3115
3116// ---------------------------------------------------------------------------
3117// Bordered — simple bordered container with title
3118// ---------------------------------------------------------------------------
3119
3120/// A simple bordered container with a title. Renders a block border and
3121/// exposes the inner area for composing other widgets.
3122#[derive(Clone, Debug)]
3123pub struct Bordered<'a> {
3124    title: Cow<'a, str>,
3125    palette: AislingPalette,
3126}
3127
3128impl PartialEq for Bordered<'_> {
3129    fn eq(&self, other: &Self) -> bool {
3130        self.title == other.title && self.palette == other.palette
3131    }
3132}
3133
3134impl<'a> Bordered<'a> {
3135    /// Creates a bordered container with the given title.
3136    #[must_use]
3137    pub fn new(title: impl Into<Cow<'a, str>>) -> Self {
3138        Self {
3139            title: title.into(),
3140            palette: AislingPalette::cypherpunk(),
3141        }
3142    }
3143
3144    /// Sets the color palette.
3145    #[must_use]
3146    pub fn palette(mut self, palette: AislingPalette) -> Self {
3147        self.palette = palette;
3148        self
3149    }
3150
3151    /// Renders the border and returns the inner content area.
3152    pub fn render_inner(&self, buf: &mut Buffer, area: Rect) -> Rect {
3153        let block = Block::new(self.title.as_ref())
3154            .with_borders(BorderStyle::Plain)
3155            .with_border_color(self.palette.mid);
3156        let inner = block_content_area(&block, area);
3157        block.render(buf, area);
3158        inner
3159    }
3160}
3161
3162impl Widget for Bordered<'_> {
3163    fn render(&self, buf: &mut Buffer, area: Rect) {
3164        self.render_inner(buf, area);
3165    }
3166}
3167
3168// ---------------------------------------------------------------------------
3169// Helpers
3170// ---------------------------------------------------------------------------
3171
3172fn is_empty(area: Rect) -> bool {
3173    area.width == 0 || area.height == 0
3174}
3175
3176fn block_content_area(block: &Block<'_>, area: Rect) -> Rect {
3177    match block.borders {
3178        BorderStyle::None => area,
3179        _ => Rect::new(
3180            area.x.saturating_add(1),
3181            area.y.saturating_add(1),
3182            area.width.saturating_sub(2),
3183            area.height.saturating_sub(2),
3184        ),
3185    }
3186}
3187
3188fn option_block_eq(left: Option<&Block<'_>>, right: Option<&Block<'_>>) -> bool {
3189    match (left, right) {
3190        (Some(left), Some(right)) => block_eq(left, right),
3191        (None, None) => true,
3192        _ => false,
3193    }
3194}
3195
3196fn block_eq(left: &Block<'_>, right: &Block<'_>) -> bool {
3197    left.title == right.title
3198        && left.title_right == right.title_right
3199        && left.borders == right.borders
3200        && left.border_color == right.border_color
3201        && left.bg == right.bg
3202        && left.style == right.style
3203        && left.inner_margin == right.inner_margin
3204}
3205
3206fn is_edge(area: Rect, x: u16, y: u16) -> bool {
3207    x == area.x
3208        || y == area.y
3209        || x + 1 == area.x.saturating_add(area.width)
3210        || y + 1 == area.y.saturating_add(area.height)
3211}
3212
3213fn field_noise(x: u16, y: u16, tick: u64) -> u64 {
3214    let mut value = u64::from(x).wrapping_mul(0x9e37_79b9_7f4a_7c15)
3215        ^ u64::from(y).wrapping_mul(0xbf58_476d_1ce4_e5b9)
3216        ^ tick.wrapping_mul(0x94d0_49bb_1331_11eb);
3217    value ^= value >> 30;
3218    value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9);
3219    value ^= value >> 27;
3220    value = value.wrapping_mul(0x94d0_49bb_1331_11eb);
3221    value ^ (value >> 31)
3222}
3223
3224fn paint_text(area: Rect, buf: &mut Buffer, text: &str, style: Style) {
3225    if is_empty(area) {
3226        return;
3227    }
3228
3229    let right = area.x.saturating_add(area.width);
3230    for (offset, glyph) in text.chars().take(usize::from(area.width)).enumerate() {
3231        let x = area.x + offset as u16;
3232        if x >= right {
3233            break;
3234        }
3235        set_styled_char(buf, x, area.y, glyph, style);
3236    }
3237}
3238
3239fn set_cell_bg(buf: &mut Buffer, x: u16, y: u16, bg: Color) {
3240    let Some(mut cell) = buf.get(usize::from(x), usize::from(y)).copied() else {
3241        return;
3242    };
3243    cell.bg = Some(bg);
3244    buf.set(usize::from(x), usize::from(y), cell);
3245}
3246
3247fn set_cell_style(buf: &mut Buffer, x: u16, y: u16, style: Style) {
3248    let Some(cell) = buf.get(usize::from(x), usize::from(y)).copied() else {
3249        return;
3250    };
3251    buf.set(usize::from(x), usize::from(y), replace_style(cell, style));
3252}
3253
3254fn set_styled_char(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) {
3255    buf.set(
3256        usize::from(x),
3257        usize::from(y),
3258        replace_style(
3259            Cell::new(ch, style.fg.unwrap_or(Color::WHITE), style.bg),
3260            style,
3261        ),
3262    );
3263}
3264
3265fn replace_style(mut cell: Cell, style: Style) -> Cell {
3266    cell.fg = style.fg.unwrap_or(Color::WHITE);
3267    cell.bg = style.bg;
3268    cell.bold = (style.bold || style.add_modifier.contains(Modifier::BOLD))
3269        && !style.sub_modifier.contains(Modifier::BOLD);
3270    cell.italic = (style.italic || style.add_modifier.contains(Modifier::ITALIC))
3271        && !style.sub_modifier.contains(Modifier::ITALIC);
3272    cell.underlined = (style.underlined || style.add_modifier.contains(Modifier::UNDERLINED))
3273        && !style.sub_modifier.contains(Modifier::UNDERLINED);
3274    cell
3275}
3276
3277#[cfg(test)]
3278mod tests {
3279    use super::*;
3280
3281    #[test]
3282    fn gauge_ratio_is_clamped() {
3283        assert_eq!(NebulaGauge::new(1.5).ratio(), 1.0);
3284        assert_eq!(NebulaGauge::new(-1.0).ratio(), 0.0);
3285    }
3286
3287    #[test]
3288    fn effect_can_be_applied_to_a_buffer() {
3289        let area = Rect::new(0, 0, 12, 4);
3290        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3291
3292        AislingEffect::new(8).intensity(7).apply(area, &mut buf);
3293    }
3294
3295    #[test]
3296    fn flicker_panel_renders_without_panic() {
3297        let area = Rect::new(0, 0, 20, 3);
3298        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3299        FlickerPanel::new("test")
3300            .tick(5)
3301            .intensity(3)
3302            .render(&mut buf, area);
3303    }
3304
3305    #[test]
3306    fn waveform_renders_without_panic() {
3307        let area = Rect::new(0, 0, 40, 10);
3308        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3309        Waveform::new(4.0, 0.6).tick(12).render(&mut buf, area);
3310    }
3311
3312    #[test]
3313    fn waveform_short_height_is_noop() {
3314        let area = Rect::new(0, 0, 20, 2);
3315        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3316        Waveform::new(4.0, 0.6).render(&mut buf, area);
3317    }
3318
3319    #[test]
3320    fn pulse_ring_renders_without_panic() {
3321        let area = Rect::new(0, 0, 30, 15);
3322        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3323        PulseRing::new(3).tick(7).render(&mut buf, area);
3324    }
3325
3326    #[test]
3327    fn pulse_ring_zero_area_is_noop() {
3328        let area = Rect::new(0, 0, 0, 0);
3329        let mut buf = Buffer::new(1, 1);
3330        PulseRing::new(5).render(&mut buf, area);
3331    }
3332
3333    #[test]
3334    fn radar_renders_without_panic() {
3335        let area = Rect::new(0, 0, 20, 20);
3336        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3337        Radar::new(5).tick(10).render(&mut buf, area);
3338    }
3339
3340    #[test]
3341    fn radar_small_area_is_noop() {
3342        let area = Rect::new(0, 0, 1, 1);
3343        let mut buf = Buffer::new(1, 1);
3344        Radar::new(5).render(&mut buf, area);
3345    }
3346
3347    #[test]
3348    fn orb_field_renders_without_panic() {
3349        let area = Rect::new(0, 0, 30, 10);
3350        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3351        OrbField::new(8).tick(5).render(&mut buf, area);
3352    }
3353
3354    #[test]
3355    fn neon_border_renders_without_panic() {
3356        let area = Rect::new(0, 0, 20, 10);
3357        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3358        NeonBorder::new(Block::new("test"))
3359            .tick(12)
3360            .render(&mut buf, area);
3361    }
3362
3363    #[test]
3364    fn stream_panel_renders_without_panic() {
3365        let area = Rect::new(0, 0, 40, 10);
3366        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3367        StreamPanel::new()
3368            .push_line("fn main() {")
3369            .push_line("    println!(\"hello\");")
3370            .push_line("}")
3371            .show_line_numbers(true)
3372            .tick(5)
3373            .render(&mut buf, area);
3374    }
3375
3376    #[test]
3377    fn stream_panel_empty_is_noop() {
3378        let area = Rect::new(0, 0, 10, 5);
3379        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3380        StreamPanel::new().render(&mut buf, area);
3381    }
3382
3383    #[test]
3384    fn stream_panel_follow_tail() {
3385        let lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
3386        let area = Rect::new(0, 0, 30, 5);
3387        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3388        let panel = StreamPanel::new()
3389            .lines(lines)
3390            .follow_tail(true)
3391            .show_line_numbers(true);
3392        panel.render(&mut buf, area);
3393        assert_eq!(panel.line_count(), 50);
3394    }
3395
3396    #[test]
3397    fn split_pane_vertical() {
3398        let area = Rect::new(0, 0, 80, 24);
3399        let (a, b, div) = SplitPane::vertical().ratio(0.6).split(area);
3400        assert_eq!(a.width, 48);
3401        assert_eq!(b.width, 32);
3402        assert_eq!(div.width, 0);
3403    }
3404
3405    #[test]
3406    fn split_pane_vertical_with_divider() {
3407        let area = Rect::new(0, 0, 80, 24);
3408        let (a, b, div) = SplitPane::vertical().ratio(0.5).divider('│').split(area);
3409        assert_eq!(a.width + b.width + div.width, 80);
3410        assert_eq!(div.width, 1);
3411    }
3412
3413    #[test]
3414    fn split_pane_horizontal() {
3415        let area = Rect::new(0, 0, 80, 24);
3416        let (a, b, _div) = SplitPane::horizontal().ratio(0.75).split(area);
3417        assert_eq!(a.height, 18);
3418        assert_eq!(b.height, 6);
3419    }
3420
3421    #[test]
3422    fn split_pane_empty_area() {
3423        let (a, b, div) = SplitPane::vertical().split(Rect::ZERO);
3424        assert_eq!(a, Rect::ZERO);
3425        assert_eq!(b, Rect::ZERO);
3426        assert_eq!(div, Rect::ZERO);
3427    }
3428
3429    #[test]
3430    fn list_renders_without_panic() {
3431        let area = Rect::new(0, 0, 30, 8);
3432        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3433        List::new()
3434            .item("Apple")
3435            .item("Banana")
3436            .item("Cherry")
3437            .selected(Some(1))
3438            .render(&mut buf, area);
3439    }
3440
3441    #[test]
3442    fn list_scrolls_to_selected() {
3443        let items: Vec<String> = (0..30).map(|i| format!("Item {i}")).collect();
3444        let area = Rect::new(0, 0, 20, 5);
3445        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3446        List::new()
3447            .items(items)
3448            .selected(Some(25))
3449            .render(&mut buf, area);
3450    }
3451
3452    #[test]
3453    fn tab_bar_renders_without_panic() {
3454        let area = Rect::new(0, 0, 60, 3);
3455        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3456        TabBar::new(["Tab 1", "Tab 2", "Tab 3"])
3457            .selected(1)
3458            .tick(3)
3459            .render(&mut buf, area);
3460    }
3461
3462    #[test]
3463    fn tab_bar_many_tabs() {
3464        let area = Rect::new(0, 0, 20, 1);
3465        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3466        TabBar::new(["A", "B", "C", "D", "E", "F", "G", "H"]).render(&mut buf, area);
3467    }
3468
3469    #[test]
3470    fn table_renders_without_panic() {
3471        let area = Rect::new(0, 0, 60, 10);
3472        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3473        Table::new(["Name", "Age", "City"])
3474            .row(["Alice", "30", "NYC"])
3475            .row(["Bob", "25", "LA"])
3476            .row(["Carol", "35", "Chicago"])
3477            .selected(Some(1))
3478            .render(&mut buf, area);
3479    }
3480
3481    #[test]
3482    fn table_with_explicit_widths() {
3483        let area = Rect::new(0, 0, 40, 5);
3484        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3485        Table::new(["A", "B"])
3486            .row(["x", "y"])
3487            .widths(vec![20, 20])
3488            .render(&mut buf, area);
3489    }
3490
3491    #[test]
3492    fn sparkline_renders_without_panic() {
3493        let area = Rect::new(0, 0, 30, 5);
3494        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3495        Sparkline::new(vec![1, 3, 5, 2, 8, 4, 6, 3, 7, 9]).render(&mut buf, area);
3496    }
3497
3498    #[test]
3499    fn sparkline_with_max_value() {
3500        let area = Rect::new(0, 0, 20, 3);
3501        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3502        Sparkline::new(vec![5, 10, 15])
3503            .max_value(20)
3504            .render(&mut buf, area);
3505    }
3506
3507    #[test]
3508    fn sparkline_empty_data_is_noop() {
3509        let area = Rect::new(0, 0, 20, 3);
3510        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3511        Sparkline::new(vec![]).render(&mut buf, area);
3512    }
3513
3514    #[test]
3515    fn gauge_simple_renders_without_panic() {
3516        let area = Rect::new(0, 0, 30, 3);
3517        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3518        Gauge::new(0.65).label("65%").render(&mut buf, area);
3519    }
3520
3521    #[test]
3522    fn simple_gauge_ratio_is_clamped() {
3523        assert_eq!(Gauge::new(2.0).ratio(), 1.0);
3524        assert_eq!(Gauge::new(-1.0).ratio(), 0.0);
3525    }
3526
3527    #[test]
3528    fn paragraph_renders_without_panic() {
3529        let area = Rect::new(0, 0, 30, 8);
3530        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3531        Paragraph::new(
3532            "Hello world. This is a longer paragraph that should wrap across multiple lines.",
3533        )
3534        .render(&mut buf, area);
3535    }
3536
3537    #[test]
3538    fn paragraph_scrolls() {
3539        let text = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8";
3540        let area = Rect::new(0, 0, 20, 3);
3541        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3542        Paragraph::new(text).scroll_offset(3).render(&mut buf, area);
3543    }
3544
3545    #[test]
3546    fn status_bar_renders_without_panic() {
3547        let area = Rect::new(0, 0, 60, 1);
3548        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3549        StatusBar::new()
3550            .left("Left")
3551            .center("Center")
3552            .right("Right")
3553            .render(&mut buf, area);
3554    }
3555
3556    #[test]
3557    fn status_bar_only_left() {
3558        let area = Rect::new(0, 0, 20, 1);
3559        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3560        StatusBar::new().left("Hello").render(&mut buf, area);
3561    }
3562
3563    #[test]
3564    fn bordered_renders_without_panic() {
3565        let area = Rect::new(0, 0, 30, 10);
3566        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3567        Bordered::new("Container").render(&mut buf, area);
3568    }
3569
3570    #[test]
3571    fn bordered_returns_inner_area() {
3572        let area = Rect::new(0, 0, 30, 10);
3573        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3574        let inner = Bordered::new("Title").render_inner(&mut buf, area);
3575        assert_eq!(inner.x, 1);
3576        assert_eq!(inner.y, 1);
3577        assert_eq!(inner.width, 28);
3578        assert_eq!(inner.height, 8);
3579    }
3580}