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