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