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