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, ScrollRowHit, SelectableSpan, SelectionGroup, TextRange,
11        WidgetAction, WidgetId, WidgetRole, WidgetState, 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        let id = id.into();
1518        self.render(frame.buffer(), area);
1519        for region in self.hit_regions(id.clone(), area) {
1520            frame.register_hit_region(region);
1521        }
1522        for span in self.selectable_spans(id.clone(), area) {
1523            frame.register_selectable_span(span);
1524        }
1525        if let Some((viewport, start, rows)) = self.scroll_region(id.clone(), area) {
1526            frame.register_scroll_region(id, viewport, start, rows);
1527        }
1528        frame.mark_dirty(area);
1529    }
1530
1531    /// Builds Scrin interaction metadata for the visible stream rows.
1532    #[must_use]
1533    pub fn hit_regions(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<HitRegion> {
1534        let region_id = id.into();
1535        let inner = self
1536            .block
1537            .as_ref()
1538            .map_or(area, |block| block_content_area(block, area));
1539        if is_empty(area) || is_empty(inner) {
1540            return Vec::new();
1541        }
1542
1543        let mut regions = vec![
1544            HitRegion::new(region_id.clone(), area)
1545                .with_role(WidgetRole::Transcript)
1546                .with_label("stream")
1547                .with_value(WidgetValue::Count(self.lines.len())),
1548        ];
1549        let start = self.visible_start(inner.height);
1550
1551        for row in 0..inner.height {
1552            let line_idx = start + row as usize;
1553            if line_idx >= self.lines.len() {
1554                break;
1555            }
1556
1557            let row_area = Rect::new(inner.x, inner.y + row, inner.width, 1);
1558            regions.push(
1559                HitRegion::new(format!("{}:line:{line_idx}", region_id.as_ref()), row_area)
1560                    .with_role(WidgetRole::TranscriptRow)
1561                    .with_label(self.lines[line_idx].as_ref())
1562                    .with_action(WidgetAction::Select)
1563                    .with_cursor(MouseCursor::Text)
1564                    .with_row(line_idx)
1565                    .with_value(WidgetValue::LineNumber(line_idx + 1))
1566                    .with_z_index(1),
1567            );
1568        }
1569
1570        regions
1571    }
1572
1573    /// Builds selectable spans for the visible stream text.
1574    #[must_use]
1575    pub fn selectable_spans(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<SelectableSpan> {
1576        let region_id = id.into();
1577        let inner = self
1578            .block
1579            .as_ref()
1580            .map_or(area, |block| block_content_area(block, area));
1581        if is_empty(inner) {
1582            return Vec::new();
1583        }
1584
1585        let gutter_width = self.gutter_width();
1586        let text_width = inner.width.saturating_sub(gutter_width);
1587        if text_width == 0 {
1588            return Vec::new();
1589        }
1590
1591        let group = SelectionGroup::new(format!("{}:lines", region_id.as_ref()));
1592        let start = self.visible_start(inner.height);
1593        let mut spans = Vec::new();
1594
1595        for row in 0..inner.height {
1596            let line_idx = start + row as usize;
1597            if line_idx >= self.lines.len() {
1598                break;
1599            }
1600
1601            let text = clipped_text(self.lines[line_idx].as_ref(), text_width);
1602            if text.is_empty() {
1603                continue;
1604            }
1605
1606            let width = text.chars().count().min(usize::from(text_width)) as u16;
1607            spans.push(
1608                SelectableSpan::from_logical(
1609                    format!("{}:span:{line_idx}", region_id.as_ref()),
1610                    region_id.clone(),
1611                    Rect::new(inner.x + gutter_width, inner.y + row, width, 1),
1612                    TextRange::new(line_idx, 0, width as usize),
1613                    text,
1614                )
1615                .with_group(group.clone()),
1616            );
1617        }
1618
1619        spans
1620    }
1621
1622    /// Builds Scrin scroll-region metadata for visible stream rows.
1623    #[must_use]
1624    pub fn scroll_region(
1625        &self,
1626        id: impl Into<WidgetId>,
1627        area: Rect,
1628    ) -> Option<(Rect, usize, Vec<ScrollRowHit>)> {
1629        let region_id = id.into();
1630        let inner = self
1631            .block
1632            .as_ref()
1633            .map_or(area, |block| block_content_area(block, area));
1634        if is_empty(inner) {
1635            return None;
1636        }
1637
1638        let start = self.visible_start(inner.height);
1639        let mut rows = Vec::new();
1640        for row in 0..inner.height {
1641            let line_idx = start + row as usize;
1642            if line_idx >= self.lines.len() {
1643                break;
1644            }
1645            let row_id = WidgetId::new(format!("{}:line:{line_idx}", region_id.as_ref()));
1646            rows.push(
1647                ScrollRowHit::new(row_id.clone(), line_idx)
1648                    .with_source_line(line_idx)
1649                    .with_span_id(format!("{}:span:{line_idx}", region_id.as_ref()))
1650                    .with_item_id(row_id),
1651            );
1652        }
1653
1654        Some((inner, start, rows))
1655    }
1656
1657    /// Returns the number of lines currently held.
1658    #[must_use]
1659    pub fn line_count(&self) -> usize {
1660        self.lines.len()
1661    }
1662
1663    fn gutter_width(&self) -> u16 {
1664        if self.show_line_numbers {
1665            let max_num = self.lines.len().max(1);
1666            let digits = format!("{max_num}").len() as u16;
1667            digits + 1
1668        } else {
1669            0
1670        }
1671    }
1672
1673    /// Computes the first visible line index given the inner height.
1674    fn visible_start(&self, visible_height: u16) -> usize {
1675        let total = self.lines.len() as u16;
1676        if self.follow_tail || self.scroll_offset == 0 {
1677            let shown = visible_height.min(total);
1678            (total - shown) as usize
1679        } else {
1680            let max_top = total.saturating_sub(visible_height);
1681            (max_top.saturating_sub(self.scroll_offset)) as usize
1682        }
1683    }
1684}
1685
1686impl Default for StreamPanel<'_> {
1687    fn default() -> Self {
1688        Self::new()
1689    }
1690}
1691
1692impl Widget for StreamPanel<'_> {
1693    fn render(&self, buf: &mut Buffer, area: Rect) {
1694        let inner = self
1695            .block
1696            .as_ref()
1697            .map_or(area, |block| block_content_area(block, area));
1698        if let Some(block) = &self.block {
1699            block.render(buf, area);
1700        }
1701        if is_empty(inner) {
1702            return;
1703        }
1704
1705        let gutter_width = self.gutter_width();
1706
1707        let text_width = inner.width.saturating_sub(gutter_width);
1708        if text_width == 0 {
1709            return;
1710        }
1711
1712        let start = self.visible_start(inner.height);
1713        let right = inner.x.saturating_add(inner.width);
1714        let total = self.lines.len();
1715
1716        for row in 0..inner.height {
1717            let line_idx = start + row as usize;
1718            let y = inner.y + row;
1719
1720            if line_idx >= total {
1721                break;
1722            }
1723
1724            if self.show_line_numbers {
1725                let num_str = format!(
1726                    "{:>width$}",
1727                    line_idx + 1,
1728                    width = (gutter_width - 1) as usize
1729                );
1730                paint_text(
1731                    Rect::new(inner.x, y, gutter_width.saturating_sub(1), 1),
1732                    buf,
1733                    &num_str,
1734                    Style::default().fg(self.palette.shadow),
1735                );
1736                set_styled_char(
1737                    buf,
1738                    inner.x + gutter_width - 1,
1739                    y,
1740                    '│',
1741                    Style::default().fg(self.palette.shadow),
1742                );
1743            }
1744
1745            let line = &self.lines[line_idx];
1746            let text_chars: Vec<char> = line.chars().collect();
1747
1748            for col in 0..text_width {
1749                let x = inner.x + gutter_width + col;
1750                if x >= right {
1751                    break;
1752                }
1753                let ch = text_chars.get(col as usize).copied().unwrap_or(' ');
1754                let noise = field_noise(x, y, self.tick);
1755                let style = if ch == ' ' {
1756                    Style::default()
1757                } else if (noise + self.tick) % 31 == 0 {
1758                    Style::default()
1759                        .fg(self.palette.pulse)
1760                        .add_modifier(Modifier::BOLD)
1761                } else {
1762                    Style::default().fg(self.palette.high)
1763                };
1764                set_styled_char(buf, x, y, ch, style);
1765            }
1766        }
1767
1768        if !self.follow_tail && self.scroll_offset > 0 {
1769            let indicator_y = inner.y;
1770            let indicator_style = Style::default()
1771                .fg(self.palette.pulse)
1772                .add_modifier(Modifier::BOLD);
1773            if inner.width > 2 {
1774                set_styled_char(buf, right - 2, indicator_y, '▲', indicator_style);
1775            }
1776        }
1777    }
1778}
1779
1780// ---------------------------------------------------------------------------
1781// SplitPane — dynamic horizontal / vertical split with divider
1782// ---------------------------------------------------------------------------
1783
1784/// Direction for a split pane.
1785#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1786pub enum SplitDirection {
1787    Horizontal,
1788    Vertical,
1789}
1790
1791/// Computes two sub-areas from a parent area with a ratio and optional divider.
1792///
1793/// The ratio controls how much space the first pane gets (0.0..1.0).
1794/// The divider is rendered between the two panes and takes 1 cell.
1795pub struct SplitPane {
1796    ratio: f64,
1797    direction: SplitDirection,
1798    divider: Option<char>,
1799}
1800
1801impl SplitPane {
1802    /// Creates a horizontal split (top/bottom) with 50/50 ratio.
1803    #[must_use]
1804    pub fn horizontal() -> Self {
1805        Self {
1806            ratio: 0.5,
1807            direction: SplitDirection::Horizontal,
1808            divider: None,
1809        }
1810    }
1811
1812    /// Creates a vertical split (left/right) with 50/50 ratio.
1813    #[must_use]
1814    pub fn vertical() -> Self {
1815        Self {
1816            ratio: 0.5,
1817            direction: SplitDirection::Vertical,
1818            divider: None,
1819        }
1820    }
1821
1822    /// Sets the ratio for the first pane (clamped 0.0..1.0).
1823    #[must_use]
1824    pub fn ratio(mut self, ratio: f64) -> Self {
1825        self.ratio = ratio.clamp(0.0, 1.0);
1826        self
1827    }
1828
1829    /// Sets the divider character rendered between panes.
1830    #[must_use]
1831    pub fn divider(mut self, divider: char) -> Self {
1832        self.divider = Some(divider);
1833        self
1834    }
1835
1836    /// Splits the area and returns (pane_a, pane_b, divider_area).
1837    /// divider_area is zero-sized if no divider.
1838    pub fn split(&self, area: Rect) -> (Rect, Rect, Rect) {
1839        if is_empty(area) {
1840            return (Rect::ZERO, Rect::ZERO, Rect::ZERO);
1841        }
1842
1843        match self.direction {
1844            SplitDirection::Vertical => {
1845                let has_divider = self.divider.is_some() && area.width > 1;
1846                let available = if has_divider {
1847                    area.width.saturating_sub(1)
1848                } else {
1849                    area.width
1850                };
1851                let first_width = (f64::from(available) * self.ratio).round() as u16;
1852                let second_width = available.saturating_sub(first_width);
1853
1854                let a = Rect::new(area.x, area.y, first_width, area.height);
1855                let div = if has_divider {
1856                    Rect::new(area.x + first_width, area.y, 1, area.height)
1857                } else {
1858                    Rect::ZERO
1859                };
1860                let b_x = area.x + first_width + if has_divider { 1 } else { 0 };
1861                let b = Rect::new(b_x, area.y, second_width, area.height);
1862                (a, b, div)
1863            }
1864            SplitDirection::Horizontal => {
1865                let has_divider = self.divider.is_some() && area.height > 1;
1866                let available = if has_divider {
1867                    area.height.saturating_sub(1)
1868                } else {
1869                    area.height
1870                };
1871                let first_height = (f64::from(available) * self.ratio).round() as u16;
1872                let second_height = available.saturating_sub(first_height);
1873
1874                let a = Rect::new(area.x, area.y, area.width, first_height);
1875                let div = if has_divider {
1876                    Rect::new(area.x, area.y + first_height, area.width, 1)
1877                } else {
1878                    Rect::ZERO
1879                };
1880                let b_y = area.y + first_height + if has_divider { 1 } else { 0 };
1881                let b = Rect::new(area.x, b_y, area.width, second_height);
1882                (a, b, div)
1883            }
1884        }
1885    }
1886
1887    /// Renders the divider character into the divider area.
1888    pub fn render_divider(&self, buf: &mut Buffer, divider_area: Rect, palette: AislingPalette) {
1889        if is_empty(divider_area) {
1890            return;
1891        }
1892        let ch = self.divider.unwrap_or(' ');
1893        let style = Style::default().fg(palette.mid);
1894        for y in divider_area.y..divider_area.y.saturating_add(divider_area.height) {
1895            for x in divider_area.x..divider_area.x.saturating_add(divider_area.width) {
1896                set_styled_char(buf, x, y, ch, style);
1897            }
1898        }
1899    }
1900}
1901
1902// ---------------------------------------------------------------------------
1903// List — scrollable list with selection highlighting
1904// ---------------------------------------------------------------------------
1905
1906/// A scrollable list with item selection highlighting.
1907#[derive(Clone, Debug)]
1908pub struct List<'a> {
1909    items: Vec<Cow<'a, str>>,
1910    selected: Option<usize>,
1911    scroll_offset: u16,
1912    tick: u64,
1913    palette: AislingPalette,
1914    block: Option<Block<'a>>,
1915}
1916
1917impl PartialEq for List<'_> {
1918    fn eq(&self, other: &Self) -> bool {
1919        self.items == other.items
1920            && self.selected == other.selected
1921            && self.scroll_offset == other.scroll_offset
1922            && self.tick == other.tick
1923            && self.palette == other.palette
1924            && option_block_eq(self.block.as_ref(), other.block.as_ref())
1925    }
1926}
1927
1928impl<'a> List<'a> {
1929    /// Creates an empty list.
1930    #[must_use]
1931    pub fn new() -> Self {
1932        Self {
1933            items: Vec::new(),
1934            selected: None,
1935            scroll_offset: 0,
1936            tick: 0,
1937            palette: AislingPalette::cypherpunk(),
1938            block: None,
1939        }
1940    }
1941
1942    /// Adds an item to the list.
1943    #[must_use]
1944    pub fn item(mut self, item: impl Into<Cow<'a, str>>) -> Self {
1945        self.items.push(item.into());
1946        self
1947    }
1948
1949    /// Replaces all items.
1950    #[must_use]
1951    pub fn items<I, S>(mut self, items: I) -> Self
1952    where
1953        I: IntoIterator<Item = S>,
1954        S: Into<Cow<'a, str>>,
1955    {
1956        self.items = items.into_iter().map(Into::into).collect();
1957        self
1958    }
1959
1960    /// Sets the selected index.
1961    #[must_use]
1962    pub fn selected(mut self, index: Option<usize>) -> Self {
1963        self.selected = index;
1964        self
1965    }
1966
1967    /// Sets the scroll offset from the top.
1968    #[must_use]
1969    pub fn scroll_offset(mut self, offset: u16) -> Self {
1970        self.scroll_offset = offset;
1971        self
1972    }
1973
1974    /// Sets the animation tick.
1975    #[must_use]
1976    pub fn tick(mut self, tick: u64) -> Self {
1977        self.tick = tick;
1978        self
1979    }
1980
1981    /// Sets the color palette.
1982    #[must_use]
1983    pub fn palette(mut self, palette: AislingPalette) -> Self {
1984        self.palette = palette;
1985        self
1986    }
1987
1988    /// Adds a block around the list.
1989    #[must_use]
1990    pub fn block(mut self, block: Block<'a>) -> Self {
1991        self.block = Some(block);
1992        self
1993    }
1994
1995    /// Renders into a Scrin frame and registers hit regions for visible items.
1996    pub fn render_with_interaction(
1997        &self,
1998        frame: &mut Frame<'_>,
1999        id: impl Into<WidgetId>,
2000        area: Rect,
2001    ) {
2002        let id = id.into();
2003        self.render(frame.buffer(), area);
2004        for region in self.hit_regions(id.clone(), area) {
2005            frame.register_hit_region(region);
2006        }
2007        for span in self.selectable_spans(id.clone(), area) {
2008            frame.register_selectable_span(span);
2009        }
2010        if let Some((viewport, start, rows)) = self.scroll_region(id.clone(), area) {
2011            frame.register_scroll_region(id, viewport, start, rows);
2012        }
2013        frame.mark_dirty(area);
2014    }
2015
2016    /// Builds Scrin interaction metadata for the visible list items.
2017    #[must_use]
2018    pub fn hit_regions(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<HitRegion> {
2019        let region_id = id.into();
2020        let inner = self
2021            .block
2022            .as_ref()
2023            .map_or(area, |block| block_content_area(block, area));
2024        if is_empty(area) || is_empty(inner) {
2025            return Vec::new();
2026        }
2027
2028        let mut regions = vec![
2029            HitRegion::new(region_id.clone(), area)
2030                .with_role(WidgetRole::Region)
2031                .with_label("list")
2032                .with_value(WidgetValue::Count(self.items.len())),
2033        ];
2034        let group = SelectionGroup::new(format!("{}:items", region_id.as_ref()));
2035        let start = self.visible_start(inner.height);
2036
2037        for row in 0..inner.height {
2038            let idx = start + row as usize;
2039            if idx >= self.items.len() {
2040                break;
2041            }
2042
2043            let selected = self.selected == Some(idx);
2044            let row_area = Rect::new(inner.x, inner.y + row, inner.width, 1);
2045            regions.push(
2046                HitRegion::new(format!("{}:item:{idx}", region_id.as_ref()), row_area)
2047                    .with_role(WidgetRole::ListItem)
2048                    .with_label(self.items[idx].as_ref())
2049                    .with_action(WidgetAction::Focus)
2050                    .with_cursor(MouseCursor::Pointer)
2051                    .with_row(idx)
2052                    .with_selection_group(group.clone())
2053                    .with_state(WidgetState::default().selected(selected))
2054                    .with_z_index(1),
2055            );
2056        }
2057
2058        regions
2059    }
2060
2061    /// Builds selectable spans for the visible list item labels.
2062    #[must_use]
2063    pub fn selectable_spans(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<SelectableSpan> {
2064        let region_id = id.into();
2065        let inner = self
2066            .block
2067            .as_ref()
2068            .map_or(area, |block| block_content_area(block, area));
2069        if is_empty(inner) {
2070            return Vec::new();
2071        }
2072
2073        let text_x = inner.x.saturating_add(2);
2074        let text_width = inner.width.saturating_sub(2);
2075        if text_width == 0 {
2076            return Vec::new();
2077        }
2078
2079        let group = SelectionGroup::new(format!("{}:items", region_id.as_ref()));
2080        let start = self.visible_start(inner.height);
2081        let mut spans = Vec::new();
2082
2083        for row in 0..inner.height {
2084            let item_idx = start + row as usize;
2085            if item_idx >= self.items.len() {
2086                break;
2087            }
2088
2089            let text = clipped_text(self.items[item_idx].as_ref(), text_width);
2090            if text.is_empty() {
2091                continue;
2092            }
2093
2094            let width = text.chars().count().min(usize::from(text_width)) as u16;
2095            spans.push(
2096                SelectableSpan::from_logical(
2097                    format!("{}:span:{item_idx}", region_id.as_ref()),
2098                    region_id.clone(),
2099                    Rect::new(text_x, inner.y + row, width, 1),
2100                    TextRange::new(item_idx, 0, width as usize),
2101                    text,
2102                )
2103                .with_group(group.clone()),
2104            );
2105        }
2106
2107        spans
2108    }
2109
2110    /// Builds Scrin scroll-region metadata for visible list rows.
2111    #[must_use]
2112    pub fn scroll_region(
2113        &self,
2114        id: impl Into<WidgetId>,
2115        area: Rect,
2116    ) -> Option<(Rect, usize, Vec<ScrollRowHit>)> {
2117        let region_id = id.into();
2118        let inner = self
2119            .block
2120            .as_ref()
2121            .map_or(area, |block| block_content_area(block, area));
2122        if is_empty(inner) {
2123            return None;
2124        }
2125
2126        let start = self.visible_start(inner.height);
2127        let mut rows = Vec::new();
2128        for row in 0..inner.height {
2129            let item_idx = start + row as usize;
2130            if item_idx >= self.items.len() {
2131                break;
2132            }
2133            let row_id = WidgetId::new(format!("{}:item:{item_idx}", region_id.as_ref()));
2134            rows.push(
2135                ScrollRowHit::new(row_id.clone(), item_idx)
2136                    .with_span_id(format!("{}:span:{item_idx}", region_id.as_ref()))
2137                    .with_item_id(row_id),
2138            );
2139        }
2140
2141        Some((inner, start, rows))
2142    }
2143
2144    /// Returns the number of items.
2145    #[must_use]
2146    pub fn item_count(&self) -> usize {
2147        self.items.len()
2148    }
2149
2150    fn visible_start(&self, visible_height: u16) -> usize {
2151        let total = self.items.len() as u16;
2152        if let Some(sel) = self.selected {
2153            let sel = sel as u16;
2154            if sel < self.scroll_offset {
2155                return sel as usize;
2156            }
2157            if sel >= self.scroll_offset + visible_height {
2158                return (sel + 1 - visible_height) as usize;
2159            }
2160            return self.scroll_offset as usize;
2161        }
2162        let max_top = total.saturating_sub(visible_height);
2163        (self.scroll_offset.min(max_top)) as usize
2164    }
2165}
2166
2167impl Default for List<'_> {
2168    fn default() -> Self {
2169        Self::new()
2170    }
2171}
2172
2173impl Widget for List<'_> {
2174    fn render(&self, buf: &mut Buffer, area: Rect) {
2175        let inner = self
2176            .block
2177            .as_ref()
2178            .map_or(area, |block| block_content_area(block, area));
2179        if let Some(block) = &self.block {
2180            block.render(buf, area);
2181        }
2182        if is_empty(inner) {
2183            return;
2184        }
2185
2186        let start = self.visible_start(inner.height);
2187        let indicator_width = 2u16;
2188        let text_width = inner.width.saturating_sub(indicator_width);
2189
2190        for row in 0..inner.height {
2191            let idx = start + row as usize;
2192            let y = inner.y + row;
2193
2194            if idx >= self.items.len() {
2195                break;
2196            }
2197
2198            let is_selected = self.selected == Some(idx);
2199
2200            let indicator = if is_selected { "▸ " } else { "  " };
2201            let indicator_style = if is_selected {
2202                Style::default()
2203                    .fg(self.palette.pulse)
2204                    .add_modifier(Modifier::BOLD)
2205            } else {
2206                Style::default().fg(self.palette.shadow)
2207            };
2208            paint_text(
2209                Rect::new(inner.x, y, indicator_width, 1),
2210                buf,
2211                indicator,
2212                indicator_style,
2213            );
2214
2215            let item = &self.items[idx];
2216            let item_chars: Vec<char> = item.chars().collect();
2217
2218            for col in 0..text_width {
2219                let x = inner.x + indicator_width + col;
2220                let ch = item_chars.get(col as usize).copied().unwrap_or(' ');
2221                let style = if is_selected {
2222                    if ch == ' ' {
2223                        Style::default().bg(self.palette.shadow)
2224                    } else {
2225                        Style::default()
2226                            .fg(self.palette.high)
2227                            .bg(self.palette.shadow)
2228                            .add_modifier(Modifier::BOLD)
2229                    }
2230                } else if ch == ' ' {
2231                    Style::default()
2232                } else {
2233                    Style::default().fg(self.palette.high)
2234                };
2235                set_styled_char(buf, x, y, ch, style);
2236            }
2237        }
2238    }
2239}
2240
2241// ---------------------------------------------------------------------------
2242// TabBar — horizontal tab navigation
2243// ---------------------------------------------------------------------------
2244
2245/// A horizontal tab bar with a selected tab indicator.
2246#[derive(Clone, Debug)]
2247pub struct TabBar<'a> {
2248    tabs: Vec<Cow<'a, str>>,
2249    selected: usize,
2250    tick: u64,
2251    palette: AislingPalette,
2252    block: Option<Block<'a>>,
2253}
2254
2255impl PartialEq for TabBar<'_> {
2256    fn eq(&self, other: &Self) -> bool {
2257        self.tabs == other.tabs
2258            && self.selected == other.selected
2259            && self.tick == other.tick
2260            && self.palette == other.palette
2261            && option_block_eq(self.block.as_ref(), other.block.as_ref())
2262    }
2263}
2264
2265impl<'a> TabBar<'a> {
2266    /// Creates a tab bar with the given tab titles.
2267    #[must_use]
2268    pub fn new<I, S>(tabs: I) -> Self
2269    where
2270        I: IntoIterator<Item = S>,
2271        S: Into<Cow<'a, str>>,
2272    {
2273        Self {
2274            tabs: tabs.into_iter().map(Into::into).collect(),
2275            selected: 0,
2276            tick: 0,
2277            palette: AislingPalette::cypherpunk(),
2278            block: None,
2279        }
2280    }
2281
2282    /// Sets the selected tab index.
2283    #[must_use]
2284    pub fn selected(mut self, index: usize) -> Self {
2285        self.selected = index;
2286        self
2287    }
2288
2289    /// Sets the animation tick.
2290    #[must_use]
2291    pub fn tick(mut self, tick: u64) -> Self {
2292        self.tick = tick;
2293        self
2294    }
2295
2296    /// Sets the color palette.
2297    #[must_use]
2298    pub fn palette(mut self, palette: AislingPalette) -> Self {
2299        self.palette = palette;
2300        self
2301    }
2302
2303    /// Adds a block around the tab bar.
2304    #[must_use]
2305    pub fn block(mut self, block: Block<'a>) -> Self {
2306        self.block = Some(block);
2307        self
2308    }
2309
2310    /// Renders into a Scrin frame and registers hit regions for each visible tab.
2311    pub fn render_with_interaction(
2312        &self,
2313        frame: &mut Frame<'_>,
2314        id: impl Into<WidgetId>,
2315        area: Rect,
2316    ) {
2317        let id = id.into();
2318        self.render(frame.buffer(), area);
2319        for region in self.hit_regions(id.clone(), area) {
2320            frame.register_hit_region(region);
2321        }
2322        frame.mark_dirty(area);
2323    }
2324
2325    /// Builds Scrin interaction metadata for the visible tabs.
2326    #[must_use]
2327    pub fn hit_regions(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<HitRegion> {
2328        let region_id = id.into();
2329        let inner = self
2330            .block
2331            .as_ref()
2332            .map_or(area, |block| block_content_area(block, area));
2333        if is_empty(area) || is_empty(inner) {
2334            return Vec::new();
2335        }
2336
2337        let mut regions = vec![
2338            HitRegion::new(region_id.clone(), area)
2339                .with_role(WidgetRole::Region)
2340                .with_label("tabs")
2341                .with_value(WidgetValue::Count(self.tabs.len())),
2342        ];
2343        let mut x = inner.x;
2344        let right = inner.x.saturating_add(inner.width);
2345
2346        for (idx, tab) in self.tabs.iter().enumerate() {
2347            if x >= right {
2348                break;
2349            }
2350
2351            let label_width = tab.chars().count() as u16;
2352            let tab_width = label_width.saturating_add(4).min(right - x);
2353            if tab_width == 0 {
2354                break;
2355            }
2356
2357            regions.push(
2358                HitRegion::new(
2359                    format!("{}:tab:{idx}", region_id.as_ref()),
2360                    Rect::new(x, inner.y, tab_width, 1),
2361                )
2362                .with_role(WidgetRole::Tab)
2363                .with_label(tab.as_ref())
2364                .with_action(WidgetAction::Focus)
2365                .with_cursor(MouseCursor::Pointer)
2366                .with_row(idx)
2367                .with_shortcut(format!("{}", idx + 1))
2368                .with_state(WidgetState::default().selected(idx == self.selected))
2369                .with_z_index(1),
2370            );
2371
2372            x = x.saturating_add(tab_width);
2373        }
2374
2375        regions
2376    }
2377
2378    /// Returns the number of tabs.
2379    #[must_use]
2380    pub fn tab_count(&self) -> usize {
2381        self.tabs.len()
2382    }
2383}
2384
2385impl Widget for TabBar<'_> {
2386    fn render(&self, buf: &mut Buffer, area: Rect) {
2387        let inner = self
2388            .block
2389            .as_ref()
2390            .map_or(area, |block| block_content_area(block, area));
2391        if let Some(block) = &self.block {
2392            block.render(buf, area);
2393        }
2394        if is_empty(inner) {
2395            return;
2396        }
2397
2398        let mut x = inner.x;
2399        let right = inner.x.saturating_add(inner.width);
2400
2401        for (i, tab) in self.tabs.iter().enumerate() {
2402            if x >= right {
2403                break;
2404            }
2405
2406            let is_selected = i == self.selected;
2407            let label: Vec<char> = tab.chars().collect();
2408            let padding = 2u16;
2409            let tab_width = (label.len() as u16 + padding * 2).min(right - x);
2410
2411            if is_selected {
2412                set_styled_char(
2413                    buf,
2414                    x,
2415                    inner.y,
2416                    '㎍',
2417                    Style::default()
2418                        .fg(self.palette.pulse)
2419                        .add_modifier(Modifier::BOLD),
2420                );
2421            } else {
2422                set_styled_char(buf, x, inner.y, ' ', Style::default());
2423            }
2424
2425            for col in 0..tab_width {
2426                let cx = x + col;
2427                if cx >= right {
2428                    break;
2429                }
2430
2431                let char_idx = col.saturating_sub(padding) as usize;
2432                let ch = if col < padding || col >= tab_width - padding {
2433                    ' '
2434                } else if char_idx < label.len() {
2435                    label[char_idx]
2436                } else {
2437                    ' '
2438                };
2439
2440                let style = if is_selected {
2441                    Style::default()
2442                        .fg(self.palette.high)
2443                        .add_modifier(Modifier::BOLD)
2444                } else {
2445                    Style::default().fg(self.palette.mid)
2446                };
2447                set_styled_char(buf, cx, inner.y, ch, style);
2448            }
2449
2450            if is_selected {
2451                let bottom = inner.y + inner.height.saturating_sub(1);
2452                for col in 0..tab_width {
2453                    let cx = x + col;
2454                    if cx >= right {
2455                        break;
2456                    }
2457                    set_styled_char(
2458                        buf,
2459                        cx,
2460                        bottom,
2461                        '─',
2462                        Style::default().fg(self.palette.pulse),
2463                    );
2464                }
2465            }
2466
2467            x += tab_width;
2468        }
2469    }
2470}
2471
2472// ---------------------------------------------------------------------------
2473// Table — data table with headers, rows, and selection
2474// ---------------------------------------------------------------------------
2475
2476/// A data table with column headers, rows, and optional row selection.
2477#[derive(Clone, Debug)]
2478pub struct Table<'a> {
2479    headers: Vec<Cow<'a, str>>,
2480    rows: Vec<Vec<Cow<'a, str>>>,
2481    widths: Option<Vec<u16>>,
2482    selected: Option<usize>,
2483    scroll_offset: u16,
2484    tick: u64,
2485    palette: AislingPalette,
2486    block: Option<Block<'a>>,
2487}
2488
2489impl PartialEq for Table<'_> {
2490    fn eq(&self, other: &Self) -> bool {
2491        self.headers == other.headers
2492            && self.rows == other.rows
2493            && self.widths == other.widths
2494            && self.selected == other.selected
2495            && self.scroll_offset == other.scroll_offset
2496            && self.tick == other.tick
2497            && self.palette == other.palette
2498            && option_block_eq(self.block.as_ref(), other.block.as_ref())
2499    }
2500}
2501
2502impl<'a> Table<'a> {
2503    /// Creates a table with column headers.
2504    #[must_use]
2505    pub fn new<I, S>(headers: I) -> Self
2506    where
2507        I: IntoIterator<Item = S>,
2508        S: Into<Cow<'a, str>>,
2509    {
2510        Self {
2511            headers: headers.into_iter().map(Into::into).collect(),
2512            rows: Vec::new(),
2513            widths: None,
2514            selected: None,
2515            scroll_offset: 0,
2516            tick: 0,
2517            palette: AislingPalette::cypherpunk(),
2518            block: None,
2519        }
2520    }
2521
2522    /// Adds a row to the table.
2523    #[must_use]
2524    pub fn row<I, S>(mut self, row: I) -> Self
2525    where
2526        I: IntoIterator<Item = S>,
2527        S: Into<Cow<'a, str>>,
2528    {
2529        self.rows.push(row.into_iter().map(Into::into).collect());
2530        self
2531    }
2532
2533    /// Replaces all rows.
2534    #[must_use]
2535    pub fn rows<I, R, S>(mut self, rows: I) -> Self
2536    where
2537        I: IntoIterator<Item = R>,
2538        R: IntoIterator<Item = S>,
2539        S: Into<Cow<'a, str>>,
2540    {
2541        self.rows = rows
2542            .into_iter()
2543            .map(|r| r.into_iter().map(Into::into).collect())
2544            .collect();
2545        self
2546    }
2547
2548    /// Sets explicit column widths. If not set, widths are auto-distributed.
2549    #[must_use]
2550    pub fn widths(mut self, widths: Vec<u16>) -> Self {
2551        self.widths = Some(widths);
2552        self
2553    }
2554
2555    /// Sets the selected row index.
2556    #[must_use]
2557    pub fn selected(mut self, index: Option<usize>) -> Self {
2558        self.selected = index;
2559        self
2560    }
2561
2562    /// Sets the scroll offset from the top.
2563    #[must_use]
2564    pub fn scroll_offset(mut self, offset: u16) -> Self {
2565        self.scroll_offset = offset;
2566        self
2567    }
2568
2569    /// Sets the animation tick.
2570    #[must_use]
2571    pub fn tick(mut self, tick: u64) -> Self {
2572        self.tick = tick;
2573        self
2574    }
2575
2576    /// Sets the color palette.
2577    #[must_use]
2578    pub fn palette(mut self, palette: AislingPalette) -> Self {
2579        self.palette = palette;
2580        self
2581    }
2582
2583    /// Adds a block around the table.
2584    #[must_use]
2585    pub fn block(mut self, block: Block<'a>) -> Self {
2586        self.block = Some(block);
2587        self
2588    }
2589
2590    /// Renders into a Scrin frame and registers hit regions for visible rows.
2591    pub fn render_with_interaction(
2592        &self,
2593        frame: &mut Frame<'_>,
2594        id: impl Into<WidgetId>,
2595        area: Rect,
2596    ) {
2597        let id = id.into();
2598        self.render(frame.buffer(), area);
2599        for region in self.hit_regions(id.clone(), area) {
2600            frame.register_hit_region(region);
2601        }
2602        for span in self.selectable_spans(id.clone(), area) {
2603            frame.register_selectable_span(span);
2604        }
2605        if let Some((viewport, start, rows)) = self.scroll_region(id.clone(), area) {
2606            frame.register_scroll_region(id, viewport, start, rows);
2607        }
2608        frame.mark_dirty(area);
2609    }
2610
2611    /// Builds Scrin interaction metadata for the visible table rows.
2612    #[must_use]
2613    pub fn hit_regions(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<HitRegion> {
2614        let region_id = id.into();
2615        let inner = self
2616            .block
2617            .as_ref()
2618            .map_or(area, |block| block_content_area(block, area));
2619        if is_empty(area) || is_empty(inner) {
2620            return Vec::new();
2621        }
2622
2623        let mut regions = vec![
2624            HitRegion::new(region_id.clone(), area)
2625                .with_role(WidgetRole::Region)
2626                .with_label("table")
2627                .with_value(WidgetValue::Count(self.rows.len())),
2628        ];
2629        if self.headers.is_empty() || inner.height < 3 {
2630            return regions;
2631        }
2632
2633        let visible_rows = inner.height.saturating_sub(2);
2634        let start = self
2635            .scroll_offset
2636            .min((self.rows.len() as u16).saturating_sub(visible_rows.min(self.rows.len() as u16)));
2637
2638        for row_offset in 0..visible_rows {
2639            let row_idx = start as usize + row_offset as usize;
2640            if row_idx >= self.rows.len() {
2641                break;
2642            }
2643
2644            let y = inner.y + 2 + row_offset;
2645            let selected = self.selected == Some(row_idx);
2646            let label = self.rows[row_idx]
2647                .iter()
2648                .map(Cow::as_ref)
2649                .collect::<Vec<_>>()
2650                .join(" | ");
2651            regions.push(
2652                HitRegion::new(
2653                    format!("{}:row:{row_idx}", region_id.as_ref()),
2654                    Rect::new(inner.x, y, inner.width, 1),
2655                )
2656                .with_role(WidgetRole::ModelRow)
2657                .with_label(label)
2658                .with_action(WidgetAction::Focus)
2659                .with_cursor(MouseCursor::Pointer)
2660                .with_row(row_idx)
2661                .with_state(WidgetState::default().selected(selected))
2662                .with_value(WidgetValue::Count(self.rows[row_idx].len()))
2663                .with_z_index(1),
2664            );
2665        }
2666
2667        regions
2668    }
2669
2670    /// Builds selectable spans for visible table rows.
2671    #[must_use]
2672    pub fn selectable_spans(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<SelectableSpan> {
2673        let region_id = id.into();
2674        let inner = self
2675            .block
2676            .as_ref()
2677            .map_or(area, |block| block_content_area(block, area));
2678        let Some((viewport, start, _)) = self.scroll_region(region_id.clone(), area) else {
2679            return Vec::new();
2680        };
2681        if self.headers.is_empty() || is_empty(inner) || is_empty(viewport) {
2682            return Vec::new();
2683        }
2684
2685        let group = SelectionGroup::new(format!("{}:rows", region_id.as_ref()));
2686        let mut spans = Vec::new();
2687        for row_offset in 0..viewport.height {
2688            let row_idx = start + row_offset as usize;
2689            if row_idx >= self.rows.len() {
2690                break;
2691            }
2692
2693            let text = clipped_text(&self.row_label(row_idx), viewport.width);
2694            if text.is_empty() {
2695                continue;
2696            }
2697
2698            let width = text.chars().count().min(usize::from(viewport.width)) as u16;
2699            spans.push(
2700                SelectableSpan::from_logical(
2701                    format!("{}:span:{row_idx}", region_id.as_ref()),
2702                    region_id.clone(),
2703                    Rect::new(viewport.x, viewport.y + row_offset, width, 1),
2704                    TextRange::new(row_idx, 0, width as usize),
2705                    text,
2706                )
2707                .with_group(group.clone()),
2708            );
2709        }
2710
2711        spans
2712    }
2713
2714    /// Builds Scrin scroll-region metadata for visible table rows.
2715    #[must_use]
2716    pub fn scroll_region(
2717        &self,
2718        id: impl Into<WidgetId>,
2719        area: Rect,
2720    ) -> Option<(Rect, usize, Vec<ScrollRowHit>)> {
2721        let region_id = id.into();
2722        let inner = self
2723            .block
2724            .as_ref()
2725            .map_or(area, |block| block_content_area(block, area));
2726        if self.headers.is_empty() || inner.height < 3 || is_empty(inner) {
2727            return None;
2728        }
2729
2730        let visible_rows = inner.height.saturating_sub(2);
2731        let viewport = Rect::new(inner.x, inner.y + 2, inner.width, visible_rows);
2732        if is_empty(viewport) {
2733            return None;
2734        }
2735
2736        let total = self.rows.len() as u16;
2737        let start = self
2738            .scroll_offset
2739            .min(total.saturating_sub(visible_rows.min(total))) as usize;
2740        let mut rows = Vec::new();
2741        for row_offset in 0..visible_rows {
2742            let row_idx = start + row_offset as usize;
2743            if row_idx >= self.rows.len() {
2744                break;
2745            }
2746            let row_id = WidgetId::new(format!("{}:row:{row_idx}", region_id.as_ref()));
2747            rows.push(
2748                ScrollRowHit::new(row_id.clone(), row_idx)
2749                    .with_span_id(format!("{}:span:{row_idx}", region_id.as_ref()))
2750                    .with_item_id(row_id),
2751            );
2752        }
2753
2754        Some((viewport, start, rows))
2755    }
2756
2757    /// Returns the number of rows.
2758    #[must_use]
2759    pub fn row_count(&self) -> usize {
2760        self.rows.len()
2761    }
2762
2763    fn row_label(&self, row_idx: usize) -> String {
2764        self.rows[row_idx]
2765            .iter()
2766            .map(Cow::as_ref)
2767            .collect::<Vec<_>>()
2768            .join(" | ")
2769    }
2770
2771    fn compute_widths(&self, total_width: u16) -> Vec<u16> {
2772        if let Some(ref w) = self.widths {
2773            return w.clone();
2774        }
2775        let cols = self.headers.len().max(1) as u16;
2776        let per_col = total_width / cols;
2777        let mut widths = vec![per_col; cols as usize];
2778        let remainder = total_width.saturating_sub(per_col * cols);
2779        for w in widths.iter_mut().take(remainder as usize) {
2780            *w += 1;
2781        }
2782        widths
2783    }
2784}
2785
2786impl Widget for Table<'_> {
2787    fn render(&self, buf: &mut Buffer, area: Rect) {
2788        let inner = self
2789            .block
2790            .as_ref()
2791            .map_or(area, |block| block_content_area(block, area));
2792        if let Some(block) = &self.block {
2793            block.render(buf, area);
2794        }
2795        if is_empty(inner) || self.headers.is_empty() {
2796            return;
2797        }
2798
2799        let col_widths = self.compute_widths(inner.width);
2800        let header_height = 1u16;
2801        let divider_height = 1u16;
2802        let data_start_y = inner.y + header_height + divider_height;
2803        let visible_rows = inner.height.saturating_sub(header_height + divider_height);
2804
2805        for (col_idx, header) in self.headers.iter().enumerate() {
2806            if col_idx >= col_widths.len() {
2807                break;
2808            }
2809            let col_x = inner.x + col_widths[..col_idx].iter().sum::<u16>();
2810            let w = col_widths[col_idx];
2811            paint_text(
2812                Rect::new(col_x, inner.y, w, 1),
2813                buf,
2814                header.as_ref(),
2815                Style::default()
2816                    .fg(self.palette.pulse)
2817                    .add_modifier(Modifier::BOLD),
2818            );
2819        }
2820
2821        let div_y = inner.y + header_height;
2822        for col_idx in 0..self.headers.len().min(col_widths.len()) {
2823            let col_x = inner.x + col_widths[..col_idx].iter().sum::<u16>();
2824            let w = col_widths[col_idx];
2825            for dx in 0..w {
2826                set_styled_char(
2827                    buf,
2828                    col_x + dx,
2829                    div_y,
2830                    '─',
2831                    Style::default().fg(self.palette.shadow),
2832                );
2833            }
2834        }
2835
2836        let total = self.rows.len() as u16;
2837        let start = self
2838            .scroll_offset
2839            .min(total.saturating_sub(visible_rows.min(total)));
2840
2841        for row_offset in 0..visible_rows {
2842            let row_idx = start as usize + row_offset as usize;
2843            let y = data_start_y + row_offset;
2844
2845            if row_idx >= self.rows.len() {
2846                break;
2847            }
2848
2849            let is_selected = self.selected == Some(row_idx);
2850            let row = &self.rows[row_idx];
2851
2852            for (col_idx, cell) in row.iter().enumerate() {
2853                if col_idx >= col_widths.len() {
2854                    break;
2855                }
2856                let col_x = inner.x + col_widths[..col_idx].iter().sum::<u16>();
2857                let w = col_widths[col_idx];
2858
2859                let cell_chars: Vec<char> = cell.chars().collect();
2860                for dx in 0..w {
2861                    let ch = cell_chars.get(dx as usize).copied().unwrap_or(' ');
2862                    let style = if is_selected {
2863                        Style::default()
2864                            .fg(self.palette.high)
2865                            .bg(self.palette.shadow)
2866                            .add_modifier(Modifier::BOLD)
2867                    } else {
2868                        Style::default().fg(self.palette.high)
2869                    };
2870                    set_styled_char(buf, col_x + dx, y, ch, style);
2871                }
2872            }
2873        }
2874    }
2875}
2876
2877// ---------------------------------------------------------------------------
2878// Sparkline — inline mini bar chart
2879// ---------------------------------------------------------------------------
2880
2881/// An inline sparkline / mini bar chart.
2882#[derive(Clone, Debug)]
2883pub struct Sparkline<'a> {
2884    data: Vec<u16>,
2885    max_value: Option<u16>,
2886    palette: AislingPalette,
2887    block: Option<Block<'a>>,
2888}
2889
2890impl PartialEq for Sparkline<'_> {
2891    fn eq(&self, other: &Self) -> bool {
2892        self.data == other.data
2893            && self.max_value == other.max_value
2894            && self.palette == other.palette
2895            && option_block_eq(self.block.as_ref(), other.block.as_ref())
2896    }
2897}
2898
2899impl<'a> Sparkline<'a> {
2900    /// Creates a sparkline with the given data points.
2901    #[must_use]
2902    pub fn new(data: Vec<u16>) -> Self {
2903        Self {
2904            data,
2905            max_value: None,
2906            palette: AislingPalette::phosphor(),
2907            block: None,
2908        }
2909    }
2910
2911    /// Sets an explicit maximum value for scaling. If not set, uses the data max.
2912    #[must_use]
2913    pub fn max_value(mut self, max: u16) -> Self {
2914        self.max_value = Some(max);
2915        self
2916    }
2917
2918    /// Sets the color palette.
2919    #[must_use]
2920    pub fn palette(mut self, palette: AislingPalette) -> Self {
2921        self.palette = palette;
2922        self
2923    }
2924
2925    /// Adds a block around the sparkline.
2926    #[must_use]
2927    pub fn block(mut self, block: Block<'a>) -> Self {
2928        self.block = Some(block);
2929        self
2930    }
2931}
2932
2933impl Widget for Sparkline<'_> {
2934    fn render(&self, buf: &mut Buffer, area: Rect) {
2935        let inner = self
2936            .block
2937            .as_ref()
2938            .map_or(area, |block| block_content_area(block, area));
2939        if let Some(block) = &self.block {
2940            block.render(buf, area);
2941        }
2942        if is_empty(inner) || self.data.is_empty() {
2943            return;
2944        }
2945
2946        let max = self
2947            .max_value
2948            .unwrap_or_else(|| self.data.iter().copied().max().unwrap_or(1))
2949            .max(1);
2950        let bottom = inner.y.saturating_add(inner.height);
2951
2952        for col in 0..inner.width {
2953            let data_idx = (col as usize * self.data.len()) / usize::from(inner.width);
2954            let value = self.data.get(data_idx).copied().unwrap_or(0);
2955            let bar_height =
2956                ((f64::from(value) / f64::from(max)) * f64::from(inner.height)).round() as u16;
2957            let bar_y = bottom.saturating_sub(bar_height);
2958
2959            for y in bar_y..bottom {
2960                let noise = field_noise(inner.x + col, y, 0);
2961                let style = Style::default()
2962                    .fg(self.palette.lane(noise))
2963                    .add_modifier(Modifier::BOLD);
2964                set_styled_char(buf, inner.x + col, y, '█', style);
2965            }
2966
2967            for y in inner.y..bar_y {
2968                set_styled_char(
2969                    buf,
2970                    inner.x + col,
2971                    y,
2972                    '·',
2973                    Style::default().fg(self.palette.shadow),
2974                );
2975            }
2976        }
2977    }
2978}
2979
2980// ---------------------------------------------------------------------------
2981// Gauge — simple utilitarian progress bar
2982// ---------------------------------------------------------------------------
2983
2984/// A simple utilitarian progress bar (no animation, clean display).
2985#[derive(Clone, Debug)]
2986pub struct Gauge<'a> {
2987    ratio: f64,
2988    label: Option<Cow<'a, str>>,
2989    palette: AislingPalette,
2990    block: Option<Block<'a>>,
2991}
2992
2993impl PartialEq for Gauge<'_> {
2994    fn eq(&self, other: &Self) -> bool {
2995        self.ratio == other.ratio
2996            && self.label == other.label
2997            && self.palette == other.palette
2998            && option_block_eq(self.block.as_ref(), other.block.as_ref())
2999    }
3000}
3001
3002impl<'a> Gauge<'a> {
3003    /// Creates a gauge with a ratio clamped to 0.0..=1.0.
3004    #[must_use]
3005    pub fn new(ratio: f64) -> Self {
3006        Self {
3007            ratio: ratio.clamp(0.0, 1.0),
3008            label: None,
3009            palette: AislingPalette::cypherpunk(),
3010            block: None,
3011        }
3012    }
3013
3014    /// Returns the clamped ratio.
3015    #[must_use]
3016    pub fn ratio(&self) -> f64 {
3017        self.ratio
3018    }
3019
3020    /// Sets the centered label.
3021    #[must_use]
3022    pub fn label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
3023        self.label = Some(label.into());
3024        self
3025    }
3026
3027    /// Sets the color palette.
3028    #[must_use]
3029    pub fn palette(mut self, palette: AislingPalette) -> Self {
3030        self.palette = palette;
3031        self
3032    }
3033
3034    /// Adds a block around the gauge.
3035    #[must_use]
3036    pub fn block(mut self, block: Block<'a>) -> Self {
3037        self.block = Some(block);
3038        self
3039    }
3040}
3041
3042impl Widget for Gauge<'_> {
3043    fn render(&self, buf: &mut Buffer, area: Rect) {
3044        let inner = self
3045            .block
3046            .as_ref()
3047            .map_or(area, |block| block_content_area(block, area));
3048        if let Some(block) = &self.block {
3049            block.render(buf, area);
3050        }
3051        if is_empty(inner) {
3052            return;
3053        }
3054
3055        let right = inner.x.saturating_add(inner.width);
3056        let bottom = inner.y.saturating_add(inner.height);
3057        let filled = (f64::from(inner.width) * self.ratio).round() as u16;
3058
3059        for y in inner.y..bottom {
3060            for x in inner.x..right {
3061                let offset = x.saturating_sub(inner.x);
3062                if offset < filled {
3063                    set_styled_char(
3064                        buf,
3065                        x,
3066                        y,
3067                        '█',
3068                        Style::default()
3069                            .fg(self.palette.mid)
3070                            .add_modifier(Modifier::BOLD),
3071                    );
3072                } else {
3073                    set_styled_char(buf, x, y, '░', Style::default().fg(self.palette.shadow));
3074                }
3075            }
3076        }
3077
3078        if let Some(label) = &self.label {
3079            let row = inner.y + inner.height / 2;
3080            let label_width = label.chars().count().min(usize::from(inner.width)) as u16;
3081            let start = inner.x + inner.width.saturating_sub(label_width) / 2;
3082            paint_text(
3083                Rect::new(start, row, label_width, 1),
3084                buf,
3085                label.as_ref(),
3086                Style::default()
3087                    .fg(self.palette.high)
3088                    .add_modifier(Modifier::BOLD),
3089            );
3090        }
3091    }
3092}
3093
3094// ---------------------------------------------------------------------------
3095// Paragraph — scrollable text block with word wrap
3096// ---------------------------------------------------------------------------
3097
3098/// A scrollable text paragraph with word wrapping.
3099#[derive(Clone, Debug)]
3100pub struct Paragraph<'a> {
3101    text: Cow<'a, str>,
3102    scroll_offset: u16,
3103    palette: AislingPalette,
3104    block: Option<Block<'a>>,
3105}
3106
3107impl PartialEq for Paragraph<'_> {
3108    fn eq(&self, other: &Self) -> bool {
3109        self.text == other.text
3110            && self.scroll_offset == other.scroll_offset
3111            && self.palette == other.palette
3112            && option_block_eq(self.block.as_ref(), other.block.as_ref())
3113    }
3114}
3115
3116impl<'a> Paragraph<'a> {
3117    /// Creates a paragraph with the given text.
3118    #[must_use]
3119    pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
3120        Self {
3121            text: text.into(),
3122            scroll_offset: 0,
3123            palette: AislingPalette::cypherpunk(),
3124            block: None,
3125        }
3126    }
3127
3128    /// Sets the scroll offset from the top.
3129    #[must_use]
3130    pub fn scroll_offset(mut self, offset: u16) -> Self {
3131        self.scroll_offset = offset;
3132        self
3133    }
3134
3135    /// Sets the color palette.
3136    #[must_use]
3137    pub fn palette(mut self, palette: AislingPalette) -> Self {
3138        self.palette = palette;
3139        self
3140    }
3141
3142    /// Adds a block around the paragraph.
3143    #[must_use]
3144    pub fn block(mut self, block: Block<'a>) -> Self {
3145        self.block = Some(block);
3146        self
3147    }
3148
3149    fn wrap_lines(&self, width: u16) -> Vec<Cow<'_, str>> {
3150        if width == 0 {
3151            return Vec::new();
3152        }
3153        let mut result = Vec::new();
3154        for raw_line in self.text.lines() {
3155            if raw_line.is_empty() {
3156                result.push(Cow::Borrowed(""));
3157                continue;
3158            }
3159            let mut remaining = raw_line;
3160            while !remaining.is_empty() {
3161                let w = usize::from(width);
3162                if remaining.len() <= w {
3163                    result.push(Cow::Borrowed(remaining));
3164                    break;
3165                }
3166                let break_at = remaining[..w].rfind(' ').map(|p| p + 1).unwrap_or(w);
3167                result.push(Cow::Borrowed(&remaining[..break_at]));
3168                remaining = &remaining[break_at..];
3169            }
3170        }
3171        result
3172    }
3173}
3174
3175impl Widget for Paragraph<'_> {
3176    fn render(&self, buf: &mut Buffer, area: Rect) {
3177        let inner = self
3178            .block
3179            .as_ref()
3180            .map_or(area, |block| block_content_area(block, area));
3181        if let Some(block) = &self.block {
3182            block.render(buf, area);
3183        }
3184        if is_empty(inner) {
3185            return;
3186        }
3187
3188        let wrapped = self.wrap_lines(inner.width);
3189        let total = wrapped.len() as u16;
3190        let start = self
3191            .scroll_offset
3192            .min(total.saturating_sub(inner.height.min(total)));
3193
3194        for row in 0..inner.height {
3195            let line_idx = start as usize + row as usize;
3196            let y = inner.y + row;
3197
3198            if line_idx >= wrapped.len() {
3199                break;
3200            }
3201
3202            let line = &wrapped[line_idx];
3203            let chars: Vec<char> = line.chars().collect();
3204
3205            for col in 0..inner.width {
3206                let ch = chars.get(col as usize).copied().unwrap_or(' ');
3207                let style = if ch == ' ' {
3208                    Style::default()
3209                } else {
3210                    Style::default().fg(self.palette.high)
3211                };
3212                set_styled_char(buf, inner.x + col, y, ch, style);
3213            }
3214        }
3215    }
3216}
3217
3218// ---------------------------------------------------------------------------
3219// StatusBar — multi-section horizontal status bar
3220// ---------------------------------------------------------------------------
3221
3222/// Alignment for a status bar section.
3223#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3224pub enum Align {
3225    Left,
3226    Center,
3227    Right,
3228}
3229
3230/// A section within a status bar.
3231#[derive(Clone, Debug)]
3232pub struct StatusSection<'a> {
3233    text: Cow<'a, str>,
3234    align: Align,
3235}
3236
3237/// A multi-section horizontal status bar.
3238#[derive(Clone, Debug)]
3239pub struct StatusBar<'a> {
3240    sections: Vec<StatusSection<'a>>,
3241    palette: AislingPalette,
3242}
3243
3244impl PartialEq for StatusBar<'_> {
3245    fn eq(&self, other: &Self) -> bool {
3246        self.sections.len() == other.sections.len()
3247            && self
3248                .sections
3249                .iter()
3250                .zip(other.sections.iter())
3251                .all(|(a, b)| a.text == b.text && a.align == b.align)
3252            && self.palette == other.palette
3253    }
3254}
3255
3256impl<'a> StatusBar<'a> {
3257    /// Creates an empty status bar.
3258    #[must_use]
3259    pub fn new() -> Self {
3260        Self {
3261            sections: Vec::new(),
3262            palette: AislingPalette::cypherpunk(),
3263        }
3264    }
3265
3266    /// Adds a left-aligned section.
3267    #[must_use]
3268    pub fn left(mut self, text: impl Into<Cow<'a, str>>) -> Self {
3269        self.sections.push(StatusSection {
3270            text: text.into(),
3271            align: Align::Left,
3272        });
3273        self
3274    }
3275
3276    /// Adds a center-aligned section.
3277    #[must_use]
3278    pub fn center(mut self, text: impl Into<Cow<'a, str>>) -> Self {
3279        self.sections.push(StatusSection {
3280            text: text.into(),
3281            align: Align::Center,
3282        });
3283        self
3284    }
3285
3286    /// Adds a right-aligned section.
3287    #[must_use]
3288    pub fn right(mut self, text: impl Into<Cow<'a, str>>) -> Self {
3289        self.sections.push(StatusSection {
3290            text: text.into(),
3291            align: Align::Right,
3292        });
3293        self
3294    }
3295
3296    /// Sets the color palette.
3297    #[must_use]
3298    pub fn palette(mut self, palette: AislingPalette) -> Self {
3299        self.palette = palette;
3300        self
3301    }
3302}
3303
3304impl Default for StatusBar<'_> {
3305    fn default() -> Self {
3306        Self::new()
3307    }
3308}
3309
3310impl Widget for StatusBar<'_> {
3311    fn render(&self, buf: &mut Buffer, area: Rect) {
3312        if is_empty(area) || self.sections.is_empty() {
3313            return;
3314        }
3315
3316        let bg_style = Style::default()
3317            .fg(self.palette.high)
3318            .bg(self.palette.shadow);
3319
3320        for x in area.x..area.x.saturating_add(area.width) {
3321            for y in area.y..area.y.saturating_add(area.height) {
3322                set_styled_char(buf, x, y, ' ', bg_style);
3323            }
3324        }
3325
3326        let left_sections: Vec<_> = self
3327            .sections
3328            .iter()
3329            .filter(|s| s.align == Align::Left)
3330            .collect();
3331        let center_sections: Vec<_> = self
3332            .sections
3333            .iter()
3334            .filter(|s| s.align == Align::Center)
3335            .collect();
3336        let right_sections: Vec<_> = self
3337            .sections
3338            .iter()
3339            .filter(|s| s.align == Align::Right)
3340            .collect();
3341
3342        let mut x = area.x;
3343
3344        for section in &left_sections {
3345            let text: Vec<char> = section.text.chars().collect();
3346            let max_len = text
3347                .len()
3348                .min(usize::from(area.width.saturating_sub(x - area.x)));
3349            for (i, &ch) in text.iter().take(max_len).enumerate() {
3350                set_styled_char(
3351                    buf,
3352                    x + i as u16,
3353                    area.y,
3354                    ch,
3355                    Style::default()
3356                        .fg(self.palette.high)
3357                        .add_modifier(Modifier::BOLD),
3358                );
3359            }
3360            x += max_len as u16;
3361        }
3362
3363        for section in &center_sections {
3364            let text: Vec<char> = section.text.chars().collect();
3365            let available = area.width.saturating_sub(x - area.x);
3366            let start_offset = available.saturating_sub(text.len() as u16) / 2;
3367            x += start_offset;
3368            for (i, &ch) in text.iter().take(usize::from(available)).enumerate() {
3369                set_styled_char(
3370                    buf,
3371                    x + i as u16,
3372                    area.y,
3373                    ch,
3374                    Style::default()
3375                        .fg(self.palette.high)
3376                        .add_modifier(Modifier::BOLD),
3377                );
3378            }
3379            x += text.len() as u16;
3380        }
3381
3382        let right_x = area.x + area.width;
3383        let mut render_x = right_x;
3384        for section in right_sections.iter().rev() {
3385            let text: Vec<char> = section.text.chars().collect();
3386            render_x = render_x.saturating_sub(text.len() as u16);
3387            for (i, &ch) in text.iter().enumerate() {
3388                if render_x + (i as u16) >= area.x && render_x + (i as u16) < right_x {
3389                    set_styled_char(
3390                        buf,
3391                        render_x + i as u16,
3392                        area.y,
3393                        ch,
3394                        Style::default()
3395                            .fg(self.palette.high)
3396                            .add_modifier(Modifier::BOLD),
3397                    );
3398                }
3399            }
3400        }
3401    }
3402}
3403
3404// ---------------------------------------------------------------------------
3405// Bordered — simple bordered container with title
3406// ---------------------------------------------------------------------------
3407
3408/// A simple bordered container with a title. Renders a block border and
3409/// exposes the inner area for composing other widgets.
3410#[derive(Clone, Debug)]
3411pub struct Bordered<'a> {
3412    title: Cow<'a, str>,
3413    palette: AislingPalette,
3414}
3415
3416impl PartialEq for Bordered<'_> {
3417    fn eq(&self, other: &Self) -> bool {
3418        self.title == other.title && self.palette == other.palette
3419    }
3420}
3421
3422impl<'a> Bordered<'a> {
3423    /// Creates a bordered container with the given title.
3424    #[must_use]
3425    pub fn new(title: impl Into<Cow<'a, str>>) -> Self {
3426        Self {
3427            title: title.into(),
3428            palette: AislingPalette::cypherpunk(),
3429        }
3430    }
3431
3432    /// Sets the color palette.
3433    #[must_use]
3434    pub fn palette(mut self, palette: AislingPalette) -> Self {
3435        self.palette = palette;
3436        self
3437    }
3438
3439    /// Renders the border and returns the inner content area.
3440    pub fn render_inner(&self, buf: &mut Buffer, area: Rect) -> Rect {
3441        let block = Block::new(self.title.as_ref())
3442            .with_borders(BorderStyle::Plain)
3443            .with_border_color(self.palette.mid);
3444        let inner = block_content_area(&block, area);
3445        block.render(buf, area);
3446        inner
3447    }
3448}
3449
3450impl Widget for Bordered<'_> {
3451    fn render(&self, buf: &mut Buffer, area: Rect) {
3452        self.render_inner(buf, area);
3453    }
3454}
3455
3456// ---------------------------------------------------------------------------
3457// Helpers
3458// ---------------------------------------------------------------------------
3459
3460fn is_empty(area: Rect) -> bool {
3461    area.width == 0 || area.height == 0
3462}
3463
3464fn block_content_area(block: &Block<'_>, area: Rect) -> Rect {
3465    match block.borders {
3466        BorderStyle::None => area,
3467        _ => Rect::new(
3468            area.x.saturating_add(1),
3469            area.y.saturating_add(1),
3470            area.width.saturating_sub(2),
3471            area.height.saturating_sub(2),
3472        ),
3473    }
3474}
3475
3476fn option_block_eq(left: Option<&Block<'_>>, right: Option<&Block<'_>>) -> bool {
3477    match (left, right) {
3478        (Some(left), Some(right)) => block_eq(left, right),
3479        (None, None) => true,
3480        _ => false,
3481    }
3482}
3483
3484fn block_eq(left: &Block<'_>, right: &Block<'_>) -> bool {
3485    left.title == right.title
3486        && left.title_right == right.title_right
3487        && left.borders == right.borders
3488        && left.border_color == right.border_color
3489        && left.bg == right.bg
3490        && left.style == right.style
3491        && left.inner_margin == right.inner_margin
3492}
3493
3494fn is_edge(area: Rect, x: u16, y: u16) -> bool {
3495    x == area.x
3496        || y == area.y
3497        || x + 1 == area.x.saturating_add(area.width)
3498        || y + 1 == area.y.saturating_add(area.height)
3499}
3500
3501fn field_noise(x: u16, y: u16, tick: u64) -> u64 {
3502    let mut value = u64::from(x).wrapping_mul(0x9e37_79b9_7f4a_7c15)
3503        ^ u64::from(y).wrapping_mul(0xbf58_476d_1ce4_e5b9)
3504        ^ tick.wrapping_mul(0x94d0_49bb_1331_11eb);
3505    value ^= value >> 30;
3506    value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9);
3507    value ^= value >> 27;
3508    value = value.wrapping_mul(0x94d0_49bb_1331_11eb);
3509    value ^ (value >> 31)
3510}
3511
3512fn paint_text(area: Rect, buf: &mut Buffer, text: &str, style: Style) {
3513    if is_empty(area) {
3514        return;
3515    }
3516
3517    let right = area.x.saturating_add(area.width);
3518    for (offset, glyph) in text.chars().take(usize::from(area.width)).enumerate() {
3519        let x = area.x + offset as u16;
3520        if x >= right {
3521            break;
3522        }
3523        set_styled_char(buf, x, area.y, glyph, style);
3524    }
3525}
3526
3527fn clipped_text(text: &str, width: u16) -> String {
3528    text.chars().take(usize::from(width)).collect()
3529}
3530
3531fn set_cell_bg(buf: &mut Buffer, x: u16, y: u16, bg: Color) {
3532    let Some(mut cell) = buf.get(usize::from(x), usize::from(y)).copied() else {
3533        return;
3534    };
3535    cell.bg = Some(bg);
3536    buf.set(usize::from(x), usize::from(y), cell);
3537}
3538
3539fn set_cell_style(buf: &mut Buffer, x: u16, y: u16, style: Style) {
3540    let Some(cell) = buf.get(usize::from(x), usize::from(y)).copied() else {
3541        return;
3542    };
3543    buf.set(usize::from(x), usize::from(y), replace_style(cell, style));
3544}
3545
3546fn set_styled_char(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) {
3547    buf.set(
3548        usize::from(x),
3549        usize::from(y),
3550        replace_style(
3551            Cell::new(ch, style.fg.unwrap_or(Color::WHITE), style.bg),
3552            style,
3553        ),
3554    );
3555}
3556
3557fn replace_style(mut cell: Cell, style: Style) -> Cell {
3558    cell.fg = style.fg.unwrap_or(Color::WHITE);
3559    cell.bg = style.bg;
3560    cell.bold = (style.bold || style.add_modifier.contains(Modifier::BOLD))
3561        && !style.sub_modifier.contains(Modifier::BOLD);
3562    cell.italic = (style.italic || style.add_modifier.contains(Modifier::ITALIC))
3563        && !style.sub_modifier.contains(Modifier::ITALIC);
3564    cell.underlined = (style.underlined || style.add_modifier.contains(Modifier::UNDERLINED))
3565        && !style.sub_modifier.contains(Modifier::UNDERLINED);
3566    cell
3567}
3568
3569#[cfg(test)]
3570mod tests {
3571    use super::*;
3572
3573    #[test]
3574    fn gauge_ratio_is_clamped() {
3575        assert_eq!(NebulaGauge::new(1.5).ratio(), 1.0);
3576        assert_eq!(NebulaGauge::new(-1.0).ratio(), 0.0);
3577    }
3578
3579    #[test]
3580    fn effect_can_be_applied_to_a_buffer() {
3581        let area = Rect::new(0, 0, 12, 4);
3582        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3583
3584        AislingEffect::new(8).intensity(7).apply(area, &mut buf);
3585    }
3586
3587    #[test]
3588    fn flicker_panel_renders_without_panic() {
3589        let area = Rect::new(0, 0, 20, 3);
3590        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3591        FlickerPanel::new("test")
3592            .tick(5)
3593            .intensity(3)
3594            .render(&mut buf, area);
3595    }
3596
3597    #[test]
3598    fn waveform_renders_without_panic() {
3599        let area = Rect::new(0, 0, 40, 10);
3600        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3601        Waveform::new(4.0, 0.6).tick(12).render(&mut buf, area);
3602    }
3603
3604    #[test]
3605    fn waveform_short_height_is_noop() {
3606        let area = Rect::new(0, 0, 20, 2);
3607        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3608        Waveform::new(4.0, 0.6).render(&mut buf, area);
3609    }
3610
3611    #[test]
3612    fn pulse_ring_renders_without_panic() {
3613        let area = Rect::new(0, 0, 30, 15);
3614        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3615        PulseRing::new(3).tick(7).render(&mut buf, area);
3616    }
3617
3618    #[test]
3619    fn pulse_ring_zero_area_is_noop() {
3620        let area = Rect::new(0, 0, 0, 0);
3621        let mut buf = Buffer::new(1, 1);
3622        PulseRing::new(5).render(&mut buf, area);
3623    }
3624
3625    #[test]
3626    fn radar_renders_without_panic() {
3627        let area = Rect::new(0, 0, 20, 20);
3628        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3629        Radar::new(5).tick(10).render(&mut buf, area);
3630    }
3631
3632    #[test]
3633    fn radar_small_area_is_noop() {
3634        let area = Rect::new(0, 0, 1, 1);
3635        let mut buf = Buffer::new(1, 1);
3636        Radar::new(5).render(&mut buf, area);
3637    }
3638
3639    #[test]
3640    fn orb_field_renders_without_panic() {
3641        let area = Rect::new(0, 0, 30, 10);
3642        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3643        OrbField::new(8).tick(5).render(&mut buf, area);
3644    }
3645
3646    #[test]
3647    fn neon_border_renders_without_panic() {
3648        let area = Rect::new(0, 0, 20, 10);
3649        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3650        NeonBorder::new(Block::new("test"))
3651            .tick(12)
3652            .render(&mut buf, area);
3653    }
3654
3655    #[test]
3656    fn stream_panel_renders_without_panic() {
3657        let area = Rect::new(0, 0, 40, 10);
3658        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3659        StreamPanel::new()
3660            .push_line("fn main() {")
3661            .push_line("    println!(\"hello\");")
3662            .push_line("}")
3663            .show_line_numbers(true)
3664            .tick(5)
3665            .render(&mut buf, area);
3666    }
3667
3668    #[test]
3669    fn stream_panel_empty_is_noop() {
3670        let area = Rect::new(0, 0, 10, 5);
3671        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3672        StreamPanel::new().render(&mut buf, area);
3673    }
3674
3675    #[test]
3676    fn stream_panel_follow_tail() {
3677        let lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
3678        let area = Rect::new(0, 0, 30, 5);
3679        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3680        let panel = StreamPanel::new()
3681            .lines(lines)
3682            .follow_tail(true)
3683            .show_line_numbers(true);
3684        panel.render(&mut buf, area);
3685        assert_eq!(panel.line_count(), 50);
3686    }
3687
3688    #[test]
3689    fn stream_panel_exports_selectable_scroll_metadata() {
3690        let area = Rect::new(0, 0, 24, 3);
3691        let panel = StreamPanel::new()
3692            .lines(["alpha", "bravo", "charlie", "delta"])
3693            .show_line_numbers(true);
3694
3695        let spans = panel.selectable_spans("stream", area);
3696        let (_, start, rows) = panel.scroll_region("stream", area).unwrap();
3697
3698        assert_eq!(spans.len(), 3);
3699        assert_eq!(start, 1);
3700        assert_eq!(rows.len(), 3);
3701        assert_eq!(rows[0].logical_row, 1);
3702    }
3703
3704    #[test]
3705    fn split_pane_vertical() {
3706        let area = Rect::new(0, 0, 80, 24);
3707        let (a, b, div) = SplitPane::vertical().ratio(0.6).split(area);
3708        assert_eq!(a.width, 48);
3709        assert_eq!(b.width, 32);
3710        assert_eq!(div.width, 0);
3711    }
3712
3713    #[test]
3714    fn split_pane_vertical_with_divider() {
3715        let area = Rect::new(0, 0, 80, 24);
3716        let (a, b, div) = SplitPane::vertical().ratio(0.5).divider('│').split(area);
3717        assert_eq!(a.width + b.width + div.width, 80);
3718        assert_eq!(div.width, 1);
3719    }
3720
3721    #[test]
3722    fn split_pane_horizontal() {
3723        let area = Rect::new(0, 0, 80, 24);
3724        let (a, b, _div) = SplitPane::horizontal().ratio(0.75).split(area);
3725        assert_eq!(a.height, 18);
3726        assert_eq!(b.height, 6);
3727    }
3728
3729    #[test]
3730    fn split_pane_empty_area() {
3731        let (a, b, div) = SplitPane::vertical().split(Rect::ZERO);
3732        assert_eq!(a, Rect::ZERO);
3733        assert_eq!(b, Rect::ZERO);
3734        assert_eq!(div, Rect::ZERO);
3735    }
3736
3737    #[test]
3738    fn list_renders_without_panic() {
3739        let area = Rect::new(0, 0, 30, 8);
3740        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3741        List::new()
3742            .item("Apple")
3743            .item("Banana")
3744            .item("Cherry")
3745            .selected(Some(1))
3746            .render(&mut buf, area);
3747    }
3748
3749    #[test]
3750    fn list_scrolls_to_selected() {
3751        let items: Vec<String> = (0..30).map(|i| format!("Item {i}")).collect();
3752        let area = Rect::new(0, 0, 20, 5);
3753        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3754        List::new()
3755            .items(items)
3756            .selected(Some(25))
3757            .render(&mut buf, area);
3758    }
3759
3760    #[test]
3761    fn list_exports_selectable_scroll_metadata() {
3762        let area = Rect::new(0, 0, 20, 2);
3763        let list = List::new().items(["one", "two", "three"]).selected(Some(2));
3764
3765        let spans = list.selectable_spans("list", area);
3766        let regions = list.hit_regions("list", area);
3767        let (_, start, rows) = list.scroll_region("list", area).unwrap();
3768
3769        assert_eq!(spans.len(), 2);
3770        assert_eq!(regions.len(), 3);
3771        assert_eq!(start, 1);
3772        assert_eq!(rows[1].logical_row, 2);
3773    }
3774
3775    #[test]
3776    fn tab_bar_renders_without_panic() {
3777        let area = Rect::new(0, 0, 60, 3);
3778        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3779        TabBar::new(["Tab 1", "Tab 2", "Tab 3"])
3780            .selected(1)
3781            .tick(3)
3782            .render(&mut buf, area);
3783    }
3784
3785    #[test]
3786    fn tab_bar_many_tabs() {
3787        let area = Rect::new(0, 0, 20, 1);
3788        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3789        TabBar::new(["A", "B", "C", "D", "E", "F", "G", "H"]).render(&mut buf, area);
3790    }
3791
3792    #[test]
3793    fn table_renders_without_panic() {
3794        let area = Rect::new(0, 0, 60, 10);
3795        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3796        Table::new(["Name", "Age", "City"])
3797            .row(["Alice", "30", "NYC"])
3798            .row(["Bob", "25", "LA"])
3799            .row(["Carol", "35", "Chicago"])
3800            .selected(Some(1))
3801            .render(&mut buf, area);
3802    }
3803
3804    #[test]
3805    fn table_with_explicit_widths() {
3806        let area = Rect::new(0, 0, 40, 5);
3807        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3808        Table::new(["A", "B"])
3809            .row(["x", "y"])
3810            .widths(vec![20, 20])
3811            .render(&mut buf, area);
3812    }
3813
3814    #[test]
3815    fn table_exports_selectable_scroll_metadata() {
3816        let area = Rect::new(0, 0, 30, 5);
3817        let table = Table::new(["Name", "State"])
3818            .row(["alpha", "idle"])
3819            .row(["bravo", "run"])
3820            .row(["charlie", "done"]);
3821
3822        let spans = table.selectable_spans("table", area);
3823        let regions = table.hit_regions("table", area);
3824        let (viewport, start, rows) = table.scroll_region("table", area).unwrap();
3825
3826        assert_eq!(spans.len(), 3);
3827        assert_eq!(regions.len(), 4);
3828        assert_eq!(viewport.y, 2);
3829        assert_eq!(start, 0);
3830        assert_eq!(rows[2].logical_row, 2);
3831    }
3832
3833    #[test]
3834    fn sparkline_renders_without_panic() {
3835        let area = Rect::new(0, 0, 30, 5);
3836        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3837        Sparkline::new(vec![1, 3, 5, 2, 8, 4, 6, 3, 7, 9]).render(&mut buf, area);
3838    }
3839
3840    #[test]
3841    fn sparkline_with_max_value() {
3842        let area = Rect::new(0, 0, 20, 3);
3843        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3844        Sparkline::new(vec![5, 10, 15])
3845            .max_value(20)
3846            .render(&mut buf, area);
3847    }
3848
3849    #[test]
3850    fn sparkline_empty_data_is_noop() {
3851        let area = Rect::new(0, 0, 20, 3);
3852        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3853        Sparkline::new(vec![]).render(&mut buf, area);
3854    }
3855
3856    #[test]
3857    fn gauge_simple_renders_without_panic() {
3858        let area = Rect::new(0, 0, 30, 3);
3859        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3860        Gauge::new(0.65).label("65%").render(&mut buf, area);
3861    }
3862
3863    #[test]
3864    fn simple_gauge_ratio_is_clamped() {
3865        assert_eq!(Gauge::new(2.0).ratio(), 1.0);
3866        assert_eq!(Gauge::new(-1.0).ratio(), 0.0);
3867    }
3868
3869    #[test]
3870    fn paragraph_renders_without_panic() {
3871        let area = Rect::new(0, 0, 30, 8);
3872        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3873        Paragraph::new(
3874            "Hello world. This is a longer paragraph that should wrap across multiple lines.",
3875        )
3876        .render(&mut buf, area);
3877    }
3878
3879    #[test]
3880    fn paragraph_scrolls() {
3881        let text = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8";
3882        let area = Rect::new(0, 0, 20, 3);
3883        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3884        Paragraph::new(text).scroll_offset(3).render(&mut buf, area);
3885    }
3886
3887    #[test]
3888    fn status_bar_renders_without_panic() {
3889        let area = Rect::new(0, 0, 60, 1);
3890        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3891        StatusBar::new()
3892            .left("Left")
3893            .center("Center")
3894            .right("Right")
3895            .render(&mut buf, area);
3896    }
3897
3898    #[test]
3899    fn status_bar_only_left() {
3900        let area = Rect::new(0, 0, 20, 1);
3901        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3902        StatusBar::new().left("Hello").render(&mut buf, area);
3903    }
3904
3905    #[test]
3906    fn bordered_renders_without_panic() {
3907        let area = Rect::new(0, 0, 30, 10);
3908        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3909        Bordered::new("Container").render(&mut buf, area);
3910    }
3911
3912    #[test]
3913    fn bordered_returns_inner_area() {
3914        let area = Rect::new(0, 0, 30, 10);
3915        let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
3916        let inner = Bordered::new("Title").render_inner(&mut buf, area);
3917        assert_eq!(inner.x, 1);
3918        assert_eq!(inner.y, 1);
3919        assert_eq!(inner.width, 28);
3920        assert_eq!(inner.height, 8);
3921    }
3922}