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