1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4use std::borrow::Cow;
5
6use ratatui::{
7 buffer::Buffer,
8 layout::Rect,
9 style::{Color, Modifier, Style},
10 widgets::{Block, Widget},
11};
12
13pub use ratatui;
14
15pub mod prelude {
17 pub use crate::{
18 Aisling, AislingEffect, AislingExt, AislingPalette, GlyphRain, NebulaGauge, SignalPanel,
19 ratatui,
20 };
21 pub use ratatui::widgets::Widget;
22}
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub struct AislingPalette {
27 pub low: Color,
28 pub mid: Color,
29 pub high: Color,
30 pub pulse: Color,
31 pub shadow: Color,
32}
33
34impl AislingPalette {
35 #[must_use]
37 pub const fn dream() -> Self {
38 Self {
39 low: Color::Rgb(58, 192, 255),
40 mid: Color::Rgb(176, 92, 255),
41 high: Color::Rgb(255, 219, 125),
42 pulse: Color::Rgb(255, 118, 205),
43 shadow: Color::Rgb(17, 18, 35),
44 }
45 }
46
47 #[must_use]
49 pub const fn phosphor() -> Self {
50 Self {
51 low: Color::Rgb(61, 255, 142),
52 mid: Color::Rgb(19, 189, 112),
53 high: Color::Rgb(210, 255, 181),
54 pulse: Color::Rgb(135, 255, 221),
55 shadow: Color::Rgb(7, 22, 16),
56 }
57 }
58
59 #[must_use]
61 pub const fn flare() -> Self {
62 Self {
63 low: Color::Rgb(255, 107, 107),
64 mid: Color::Rgb(255, 168, 76),
65 high: Color::Rgb(255, 236, 153),
66 pulse: Color::Rgb(255, 75, 145),
67 shadow: Color::Rgb(35, 14, 24),
68 }
69 }
70
71 fn lane(self, value: u64) -> Color {
72 match value % 4 {
73 0 => self.low,
74 1 => self.mid,
75 2 => self.high,
76 _ => self.pulse,
77 }
78 }
79}
80
81impl Default for AislingPalette {
82 fn default() -> Self {
83 Self::dream()
84 }
85}
86
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub struct AislingEffect {
90 tick: u64,
91 intensity: u16,
92 palette: AislingPalette,
93 shimmer: bool,
94 scanlines: bool,
95 glow: bool,
96}
97
98impl AislingEffect {
99 #[must_use]
101 pub fn new(tick: u64) -> Self {
102 Self {
103 tick,
104 ..Self::default()
105 }
106 }
107
108 #[must_use]
110 pub fn tick(mut self, tick: u64) -> Self {
111 self.tick = tick;
112 self
113 }
114
115 #[must_use]
117 pub fn palette(mut self, palette: AislingPalette) -> Self {
118 self.palette = palette;
119 self
120 }
121
122 #[must_use]
124 pub fn intensity(mut self, intensity: u16) -> Self {
125 self.intensity = intensity.min(10);
126 self
127 }
128
129 #[must_use]
131 pub fn shimmer(mut self, enabled: bool) -> Self {
132 self.shimmer = enabled;
133 self
134 }
135
136 #[must_use]
138 pub fn scanlines(mut self, enabled: bool) -> Self {
139 self.scanlines = enabled;
140 self
141 }
142
143 #[must_use]
145 pub fn glow(mut self, enabled: bool) -> Self {
146 self.glow = enabled;
147 self
148 }
149
150 pub fn apply(self, area: Rect, buf: &mut Buffer) {
152 if area.is_empty() || self.intensity == 0 {
153 return;
154 }
155
156 let right = area.x.saturating_add(area.width);
157 let bottom = area.y.saturating_add(area.height);
158 let edge_phase = self.tick / 2;
159 let shimmer_gate = 11_u64.saturating_sub(u64::from(self.intensity.min(10)));
160
161 for y in area.y..bottom {
162 for x in area.x..right {
163 let cell = &mut buf[(x, y)];
164
165 if self.scanlines && (u64::from(y) + edge_phase).is_multiple_of(3) {
166 cell.set_bg(self.palette.shadow);
167 }
168
169 if self.shimmer {
170 let phase = u64::from(x) * 3 + u64::from(y) * 5 + self.tick;
171 if phase % 11 >= shimmer_gate {
172 cell.set_style(
173 Style::default()
174 .fg(self.palette.lane(phase))
175 .add_modifier(Modifier::BOLD),
176 );
177 }
178 }
179
180 if self.glow
181 && is_edge(area, x, y)
182 && (u64::from(x) + u64::from(y) + edge_phase) % 5 == 0
183 {
184 cell.set_style(
185 Style::default()
186 .fg(self.palette.pulse)
187 .add_modifier(Modifier::BOLD),
188 );
189 }
190 }
191 }
192 }
193}
194
195impl Default for AislingEffect {
196 fn default() -> Self {
197 Self {
198 tick: 0,
199 intensity: 5,
200 palette: AislingPalette::default(),
201 shimmer: true,
202 scanlines: true,
203 glow: true,
204 }
205 }
206}
207
208#[derive(Clone, Debug, Eq, PartialEq)]
210pub struct Aisling<W> {
211 inner: W,
212 effect: AislingEffect,
213}
214
215impl<W> Aisling<W> {
216 #[must_use]
218 pub fn new(inner: W) -> Self {
219 Self {
220 inner,
221 effect: AislingEffect::default(),
222 }
223 }
224
225 #[must_use]
227 pub fn effect(mut self, effect: AislingEffect) -> Self {
228 self.effect = effect;
229 self
230 }
231
232 #[must_use]
234 pub fn tick(mut self, tick: u64) -> Self {
235 self.effect = self.effect.tick(tick);
236 self
237 }
238
239 #[must_use]
241 pub fn palette(mut self, palette: AislingPalette) -> Self {
242 self.effect = self.effect.palette(palette);
243 self
244 }
245
246 #[must_use]
248 pub fn intensity(mut self, intensity: u16) -> Self {
249 self.effect = self.effect.intensity(intensity);
250 self
251 }
252}
253
254impl<W: Widget> Widget for Aisling<W> {
255 fn render(self, area: Rect, buf: &mut Buffer) {
256 self.inner.render(area, buf);
257 self.effect.apply(area, buf);
258 }
259}
260
261pub trait AislingExt: Widget + Sized {
263 #[must_use]
265 fn aisling(self) -> Aisling<Self> {
266 Aisling::new(self)
267 }
268}
269
270impl<W: Widget> AislingExt for W {}
271
272#[derive(Clone, Debug, Eq, PartialEq)]
274pub struct GlyphRain<'a> {
275 tick: u64,
276 density: u16,
277 glyphs: Cow<'a, str>,
278 palette: AislingPalette,
279 block: Option<Block<'a>>,
280}
281
282impl<'a> GlyphRain<'a> {
283 #[must_use]
285 pub fn new(tick: u64) -> Self {
286 Self {
287 tick,
288 density: 34,
289 glyphs: Cow::Borrowed("01#$*+<>[]{}"),
290 palette: AislingPalette::phosphor(),
291 block: None,
292 }
293 }
294
295 #[must_use]
297 pub fn tick(mut self, tick: u64) -> Self {
298 self.tick = tick;
299 self
300 }
301
302 #[must_use]
304 pub fn density(mut self, density: u16) -> Self {
305 self.density = density.min(100);
306 self
307 }
308
309 #[must_use]
311 pub fn glyphs(mut self, glyphs: impl Into<Cow<'a, str>>) -> Self {
312 self.glyphs = glyphs.into();
313 self
314 }
315
316 #[must_use]
318 pub fn palette(mut self, palette: AislingPalette) -> Self {
319 self.palette = palette;
320 self
321 }
322
323 #[must_use]
325 pub fn block(mut self, block: Block<'a>) -> Self {
326 self.block = Some(block);
327 self
328 }
329}
330
331impl Widget for GlyphRain<'_> {
332 fn render(self, area: Rect, buf: &mut Buffer) {
333 let inner = self.block.as_ref().map_or(area, |block| block.inner(area));
334 if let Some(block) = self.block {
335 block.render(area, buf);
336 }
337 if inner.is_empty() || self.density == 0 {
338 return;
339 }
340
341 let glyphs: Vec<char> = self.glyphs.chars().collect();
342 if glyphs.is_empty() {
343 return;
344 }
345
346 let right = inner.x.saturating_add(inner.width);
347 let bottom = inner.y.saturating_add(inner.height);
348 for y in inner.y..bottom {
349 for x in inner.x..right {
350 let noise = field_noise(x, y, self.tick);
351 if noise % 100 >= u64::from(self.density) {
352 continue;
353 }
354
355 let glyph = glyphs[(noise as usize + usize::from(y)) % glyphs.len()];
356 let mut encoded = [0; 4];
357 let symbol = glyph.encode_utf8(&mut encoded);
358 let head = (noise + self.tick) % 9 == 0;
359 let style = if head {
360 Style::default()
361 .fg(self.palette.high)
362 .add_modifier(Modifier::BOLD)
363 } else {
364 Style::default().fg(self.palette.lane(noise + self.tick))
365 };
366
367 let cell = &mut buf[(x, y)];
368 cell.set_symbol(symbol);
369 cell.set_style(style);
370 }
371 }
372 }
373}
374
375#[derive(Clone, Debug, PartialEq)]
377pub struct NebulaGauge<'a> {
378 ratio: f64,
379 tick: u64,
380 label: Option<Cow<'a, str>>,
381 palette: AislingPalette,
382 block: Option<Block<'a>>,
383}
384
385impl<'a> NebulaGauge<'a> {
386 #[must_use]
388 pub fn new(ratio: f64) -> Self {
389 Self {
390 ratio: ratio.clamp(0.0, 1.0),
391 tick: 0,
392 label: None,
393 palette: AislingPalette::dream(),
394 block: None,
395 }
396 }
397
398 #[must_use]
400 pub fn ratio(&self) -> f64 {
401 self.ratio
402 }
403
404 #[must_use]
406 pub fn tick(mut self, tick: u64) -> Self {
407 self.tick = tick;
408 self
409 }
410
411 #[must_use]
413 pub fn label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
414 self.label = Some(label.into());
415 self
416 }
417
418 #[must_use]
420 pub fn palette(mut self, palette: AislingPalette) -> Self {
421 self.palette = palette;
422 self
423 }
424
425 #[must_use]
427 pub fn block(mut self, block: Block<'a>) -> Self {
428 self.block = Some(block);
429 self
430 }
431}
432
433impl Widget for NebulaGauge<'_> {
434 fn render(self, area: Rect, buf: &mut Buffer) {
435 let inner = self.block.as_ref().map_or(area, |block| block.inner(area));
436 if let Some(block) = self.block {
437 block.render(area, buf);
438 }
439 if inner.is_empty() {
440 return;
441 }
442
443 let right = inner.x.saturating_add(inner.width);
444 let bottom = inner.y.saturating_add(inner.height);
445 let filled = (f64::from(inner.width) * self.ratio).round() as u16;
446
447 for y in inner.y..bottom {
448 for x in inner.x..right {
449 let offset = x.saturating_sub(inner.x);
450 let flow = u64::from(offset) + u64::from(y) * 2 + self.tick;
451 let cell = &mut buf[(x, y)];
452 if offset < filled {
453 cell.set_symbol("█");
454 cell.set_style(
455 Style::default()
456 .fg(self.palette.lane(flow))
457 .bg(self.palette.shadow)
458 .add_modifier(Modifier::BOLD),
459 );
460 } else {
461 cell.set_symbol("░");
462 cell.set_style(Style::default().fg(self.palette.shadow));
463 }
464 }
465 }
466
467 if let Some(label) = self.label {
468 let row = inner.y + inner.height / 2;
469 let label_width = label.chars().count().min(usize::from(inner.width)) as u16;
470 let start = inner.x + inner.width.saturating_sub(label_width) / 2;
471 paint_text(
472 Rect::new(start, row, label_width, 1),
473 buf,
474 label.as_ref(),
475 Style::default()
476 .fg(self.palette.high)
477 .add_modifier(Modifier::BOLD),
478 );
479 }
480 }
481}
482
483#[derive(Clone, Debug, Eq, PartialEq)]
485pub struct SignalPanel<'a> {
486 title: Cow<'a, str>,
487 lines: Vec<Cow<'a, str>>,
488 tick: u64,
489 palette: AislingPalette,
490}
491
492impl<'a> SignalPanel<'a> {
493 #[must_use]
495 pub fn new(title: impl Into<Cow<'a, str>>) -> Self {
496 Self {
497 title: title.into(),
498 lines: Vec::new(),
499 tick: 0,
500 palette: AislingPalette::flare(),
501 }
502 }
503
504 #[must_use]
506 pub fn line(mut self, line: impl Into<Cow<'a, str>>) -> Self {
507 self.lines.push(line.into());
508 self
509 }
510
511 #[must_use]
513 pub fn lines<I, S>(mut self, lines: I) -> Self
514 where
515 I: IntoIterator<Item = S>,
516 S: Into<Cow<'a, str>>,
517 {
518 self.lines = lines.into_iter().map(Into::into).collect();
519 self
520 }
521
522 #[must_use]
524 pub fn tick(mut self, tick: u64) -> Self {
525 self.tick = tick;
526 self
527 }
528
529 #[must_use]
531 pub fn palette(mut self, palette: AislingPalette) -> Self {
532 self.palette = palette;
533 self
534 }
535}
536
537impl Widget for SignalPanel<'_> {
538 fn render(self, area: Rect, buf: &mut Buffer) {
539 if area.is_empty() {
540 return;
541 }
542
543 let block = Block::bordered()
544 .title(self.title.as_ref())
545 .border_style(Style::default().fg(self.palette.mid));
546 let inner = block.inner(area);
547 block.render(area, buf);
548 if inner.is_empty() {
549 return;
550 }
551
552 let bars_width = inner.width.min(12);
553 let text_width = inner.width.saturating_sub(bars_width.saturating_add(1));
554 let max_lines = usize::from(inner.height);
555
556 for (index, line) in self.lines.into_iter().take(max_lines).enumerate() {
557 paint_text(
558 Rect::new(inner.x, inner.y + index as u16, text_width, 1),
559 buf,
560 line.as_ref(),
561 Style::default().fg(self.palette.high),
562 );
563 }
564
565 if bars_width == 0 {
566 return;
567 }
568
569 let bars_x = inner.x + inner.width.saturating_sub(bars_width);
570 for row in 0..inner.height {
571 for column in 0..bars_width {
572 let x = bars_x + column;
573 let y = inner.y + row;
574 let noise = field_noise(x, y, self.tick / 2);
575 let active = (noise + self.tick + u64::from(column)) % 7 <= 3;
576 let symbol = if active { "╱" } else { "·" };
577 let style = if active {
578 Style::default()
579 .fg(self.palette.lane(noise))
580 .add_modifier(Modifier::BOLD)
581 } else {
582 Style::default().fg(self.palette.shadow)
583 };
584 let cell = &mut buf[(x, y)];
585 cell.set_symbol(symbol);
586 cell.set_style(style);
587 }
588 }
589 }
590}
591
592fn is_edge(area: Rect, x: u16, y: u16) -> bool {
593 x == area.x
594 || y == area.y
595 || x + 1 == area.x.saturating_add(area.width)
596 || y + 1 == area.y.saturating_add(area.height)
597}
598
599fn field_noise(x: u16, y: u16, tick: u64) -> u64 {
600 let mut value = u64::from(x).wrapping_mul(0x9e37_79b9_7f4a_7c15)
601 ^ u64::from(y).wrapping_mul(0xbf58_476d_1ce4_e5b9)
602 ^ tick.wrapping_mul(0x94d0_49bb_1331_11eb);
603 value ^= value >> 30;
604 value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9);
605 value ^= value >> 27;
606 value = value.wrapping_mul(0x94d0_49bb_1331_11eb);
607 value ^ (value >> 31)
608}
609
610fn paint_text(area: Rect, buf: &mut Buffer, text: &str, style: Style) {
611 if area.is_empty() {
612 return;
613 }
614
615 let right = area.x.saturating_add(area.width);
616 for (offset, glyph) in text.chars().take(usize::from(area.width)).enumerate() {
617 let x = area.x + offset as u16;
618 if x >= right {
619 break;
620 }
621 let mut encoded = [0; 4];
622 let symbol = glyph.encode_utf8(&mut encoded);
623 let cell = &mut buf[(x, area.y)];
624 cell.set_symbol(symbol);
625 cell.set_style(style);
626 }
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 #[test]
634 fn gauge_ratio_is_clamped() {
635 assert_eq!(NebulaGauge::new(1.5).ratio(), 1.0);
636 assert_eq!(NebulaGauge::new(-1.0).ratio(), 0.0);
637 }
638
639 #[test]
640 fn effect_can_be_applied_to_a_buffer() {
641 let area = Rect::new(0, 0, 12, 4);
642 let mut buf = Buffer::empty(area);
643
644 AislingEffect::new(8).intensity(7).apply(area, &mut buf);
645 }
646}