Skip to main content

scrin_widgets/
lib.rs

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