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