#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
use std::borrow::Cow;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Widget},
};
pub use ratatui;
pub mod prelude {
pub use crate::{
Aisling, AislingEffect, AislingExt, AislingPalette, GlyphRain, NebulaGauge, SignalPanel,
ratatui,
};
pub use ratatui::widgets::Widget;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AislingPalette {
pub low: Color,
pub mid: Color,
pub high: Color,
pub pulse: Color,
pub shadow: Color,
}
impl AislingPalette {
#[must_use]
pub const fn dream() -> Self {
Self {
low: Color::Rgb(58, 192, 255),
mid: Color::Rgb(176, 92, 255),
high: Color::Rgb(255, 219, 125),
pulse: Color::Rgb(255, 118, 205),
shadow: Color::Rgb(17, 18, 35),
}
}
#[must_use]
pub const fn phosphor() -> Self {
Self {
low: Color::Rgb(61, 255, 142),
mid: Color::Rgb(19, 189, 112),
high: Color::Rgb(210, 255, 181),
pulse: Color::Rgb(135, 255, 221),
shadow: Color::Rgb(7, 22, 16),
}
}
#[must_use]
pub const fn flare() -> Self {
Self {
low: Color::Rgb(255, 107, 107),
mid: Color::Rgb(255, 168, 76),
high: Color::Rgb(255, 236, 153),
pulse: Color::Rgb(255, 75, 145),
shadow: Color::Rgb(35, 14, 24),
}
}
fn lane(self, value: u64) -> Color {
match value % 4 {
0 => self.low,
1 => self.mid,
2 => self.high,
_ => self.pulse,
}
}
}
impl Default for AislingPalette {
fn default() -> Self {
Self::dream()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AislingEffect {
tick: u64,
intensity: u16,
palette: AislingPalette,
shimmer: bool,
scanlines: bool,
glow: bool,
}
impl AislingEffect {
#[must_use]
pub fn new(tick: u64) -> Self {
Self {
tick,
..Self::default()
}
}
#[must_use]
pub fn tick(mut self, tick: u64) -> Self {
self.tick = tick;
self
}
#[must_use]
pub fn palette(mut self, palette: AislingPalette) -> Self {
self.palette = palette;
self
}
#[must_use]
pub fn intensity(mut self, intensity: u16) -> Self {
self.intensity = intensity.min(10);
self
}
#[must_use]
pub fn shimmer(mut self, enabled: bool) -> Self {
self.shimmer = enabled;
self
}
#[must_use]
pub fn scanlines(mut self, enabled: bool) -> Self {
self.scanlines = enabled;
self
}
#[must_use]
pub fn glow(mut self, enabled: bool) -> Self {
self.glow = enabled;
self
}
pub fn apply(self, area: Rect, buf: &mut Buffer) {
if area.is_empty() || self.intensity == 0 {
return;
}
let right = area.x.saturating_add(area.width);
let bottom = area.y.saturating_add(area.height);
let edge_phase = self.tick / 2;
let shimmer_gate = 11_u64.saturating_sub(u64::from(self.intensity.min(10)));
for y in area.y..bottom {
for x in area.x..right {
let cell = &mut buf[(x, y)];
if self.scanlines && (u64::from(y) + edge_phase).is_multiple_of(3) {
cell.set_bg(self.palette.shadow);
}
if self.shimmer {
let phase = u64::from(x) * 3 + u64::from(y) * 5 + self.tick;
if phase % 11 >= shimmer_gate {
cell.set_style(
Style::default()
.fg(self.palette.lane(phase))
.add_modifier(Modifier::BOLD),
);
}
}
if self.glow
&& is_edge(area, x, y)
&& (u64::from(x) + u64::from(y) + edge_phase) % 5 == 0
{
cell.set_style(
Style::default()
.fg(self.palette.pulse)
.add_modifier(Modifier::BOLD),
);
}
}
}
}
}
impl Default for AislingEffect {
fn default() -> Self {
Self {
tick: 0,
intensity: 5,
palette: AislingPalette::default(),
shimmer: true,
scanlines: true,
glow: true,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Aisling<W> {
inner: W,
effect: AislingEffect,
}
impl<W> Aisling<W> {
#[must_use]
pub fn new(inner: W) -> Self {
Self {
inner,
effect: AislingEffect::default(),
}
}
#[must_use]
pub fn effect(mut self, effect: AislingEffect) -> Self {
self.effect = effect;
self
}
#[must_use]
pub fn tick(mut self, tick: u64) -> Self {
self.effect = self.effect.tick(tick);
self
}
#[must_use]
pub fn palette(mut self, palette: AislingPalette) -> Self {
self.effect = self.effect.palette(palette);
self
}
#[must_use]
pub fn intensity(mut self, intensity: u16) -> Self {
self.effect = self.effect.intensity(intensity);
self
}
}
impl<W: Widget> Widget for Aisling<W> {
fn render(self, area: Rect, buf: &mut Buffer) {
self.inner.render(area, buf);
self.effect.apply(area, buf);
}
}
pub trait AislingExt: Widget + Sized {
#[must_use]
fn aisling(self) -> Aisling<Self> {
Aisling::new(self)
}
}
impl<W: Widget> AislingExt for W {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GlyphRain<'a> {
tick: u64,
density: u16,
glyphs: Cow<'a, str>,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl<'a> GlyphRain<'a> {
#[must_use]
pub fn new(tick: u64) -> Self {
Self {
tick,
density: 34,
glyphs: Cow::Borrowed("01#$*+<>[]{}"),
palette: AislingPalette::phosphor(),
block: None,
}
}
#[must_use]
pub fn tick(mut self, tick: u64) -> Self {
self.tick = tick;
self
}
#[must_use]
pub fn density(mut self, density: u16) -> Self {
self.density = density.min(100);
self
}
#[must_use]
pub fn glyphs(mut self, glyphs: impl Into<Cow<'a, str>>) -> Self {
self.glyphs = glyphs.into();
self
}
#[must_use]
pub fn palette(mut self, palette: AislingPalette) -> Self {
self.palette = palette;
self
}
#[must_use]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
}
impl Widget for GlyphRain<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let inner = self.block.as_ref().map_or(area, |block| block.inner(area));
if let Some(block) = self.block {
block.render(area, buf);
}
if inner.is_empty() || self.density == 0 {
return;
}
let glyphs: Vec<char> = self.glyphs.chars().collect();
if glyphs.is_empty() {
return;
}
let right = inner.x.saturating_add(inner.width);
let bottom = inner.y.saturating_add(inner.height);
for y in inner.y..bottom {
for x in inner.x..right {
let noise = field_noise(x, y, self.tick);
if noise % 100 >= u64::from(self.density) {
continue;
}
let glyph = glyphs[(noise as usize + usize::from(y)) % glyphs.len()];
let mut encoded = [0; 4];
let symbol = glyph.encode_utf8(&mut encoded);
let head = (noise + self.tick) % 9 == 0;
let style = if head {
Style::default()
.fg(self.palette.high)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.palette.lane(noise + self.tick))
};
let cell = &mut buf[(x, y)];
cell.set_symbol(symbol);
cell.set_style(style);
}
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct NebulaGauge<'a> {
ratio: f64,
tick: u64,
label: Option<Cow<'a, str>>,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl<'a> NebulaGauge<'a> {
#[must_use]
pub fn new(ratio: f64) -> Self {
Self {
ratio: ratio.clamp(0.0, 1.0),
tick: 0,
label: None,
palette: AislingPalette::dream(),
block: None,
}
}
#[must_use]
pub fn ratio(&self) -> f64 {
self.ratio
}
#[must_use]
pub fn tick(mut self, tick: u64) -> Self {
self.tick = tick;
self
}
#[must_use]
pub fn label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub fn palette(mut self, palette: AislingPalette) -> Self {
self.palette = palette;
self
}
#[must_use]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
}
impl Widget for NebulaGauge<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let inner = self.block.as_ref().map_or(area, |block| block.inner(area));
if let Some(block) = self.block {
block.render(area, buf);
}
if inner.is_empty() {
return;
}
let right = inner.x.saturating_add(inner.width);
let bottom = inner.y.saturating_add(inner.height);
let filled = (f64::from(inner.width) * self.ratio).round() as u16;
for y in inner.y..bottom {
for x in inner.x..right {
let offset = x.saturating_sub(inner.x);
let flow = u64::from(offset) + u64::from(y) * 2 + self.tick;
let cell = &mut buf[(x, y)];
if offset < filled {
cell.set_symbol("█");
cell.set_style(
Style::default()
.fg(self.palette.lane(flow))
.bg(self.palette.shadow)
.add_modifier(Modifier::BOLD),
);
} else {
cell.set_symbol("░");
cell.set_style(Style::default().fg(self.palette.shadow));
}
}
}
if let Some(label) = self.label {
let row = inner.y + inner.height / 2;
let label_width = label.chars().count().min(usize::from(inner.width)) as u16;
let start = inner.x + inner.width.saturating_sub(label_width) / 2;
paint_text(
Rect::new(start, row, label_width, 1),
buf,
label.as_ref(),
Style::default()
.fg(self.palette.high)
.add_modifier(Modifier::BOLD),
);
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SignalPanel<'a> {
title: Cow<'a, str>,
lines: Vec<Cow<'a, str>>,
tick: u64,
palette: AislingPalette,
}
impl<'a> SignalPanel<'a> {
#[must_use]
pub fn new(title: impl Into<Cow<'a, str>>) -> Self {
Self {
title: title.into(),
lines: Vec::new(),
tick: 0,
palette: AislingPalette::flare(),
}
}
#[must_use]
pub fn line(mut self, line: impl Into<Cow<'a, str>>) -> Self {
self.lines.push(line.into());
self
}
#[must_use]
pub fn lines<I, S>(mut self, lines: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Cow<'a, str>>,
{
self.lines = lines.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn tick(mut self, tick: u64) -> Self {
self.tick = tick;
self
}
#[must_use]
pub fn palette(mut self, palette: AislingPalette) -> Self {
self.palette = palette;
self
}
}
impl Widget for SignalPanel<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let block = Block::bordered()
.title(self.title.as_ref())
.border_style(Style::default().fg(self.palette.mid));
let inner = block.inner(area);
block.render(area, buf);
if inner.is_empty() {
return;
}
let bars_width = inner.width.min(12);
let text_width = inner.width.saturating_sub(bars_width.saturating_add(1));
let max_lines = usize::from(inner.height);
for (index, line) in self.lines.into_iter().take(max_lines).enumerate() {
paint_text(
Rect::new(inner.x, inner.y + index as u16, text_width, 1),
buf,
line.as_ref(),
Style::default().fg(self.palette.high),
);
}
if bars_width == 0 {
return;
}
let bars_x = inner.x + inner.width.saturating_sub(bars_width);
for row in 0..inner.height {
for column in 0..bars_width {
let x = bars_x + column;
let y = inner.y + row;
let noise = field_noise(x, y, self.tick / 2);
let active = (noise + self.tick + u64::from(column)) % 7 <= 3;
let symbol = if active { "╱" } else { "·" };
let style = if active {
Style::default()
.fg(self.palette.lane(noise))
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.palette.shadow)
};
let cell = &mut buf[(x, y)];
cell.set_symbol(symbol);
cell.set_style(style);
}
}
}
}
fn is_edge(area: Rect, x: u16, y: u16) -> bool {
x == area.x
|| y == area.y
|| x + 1 == area.x.saturating_add(area.width)
|| y + 1 == area.y.saturating_add(area.height)
}
fn field_noise(x: u16, y: u16, tick: u64) -> u64 {
let mut value = u64::from(x).wrapping_mul(0x9e37_79b9_7f4a_7c15)
^ u64::from(y).wrapping_mul(0xbf58_476d_1ce4_e5b9)
^ tick.wrapping_mul(0x94d0_49bb_1331_11eb);
value ^= value >> 30;
value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9);
value ^= value >> 27;
value = value.wrapping_mul(0x94d0_49bb_1331_11eb);
value ^ (value >> 31)
}
fn paint_text(area: Rect, buf: &mut Buffer, text: &str, style: Style) {
if area.is_empty() {
return;
}
let right = area.x.saturating_add(area.width);
for (offset, glyph) in text.chars().take(usize::from(area.width)).enumerate() {
let x = area.x + offset as u16;
if x >= right {
break;
}
let mut encoded = [0; 4];
let symbol = glyph.encode_utf8(&mut encoded);
let cell = &mut buf[(x, area.y)];
cell.set_symbol(symbol);
cell.set_style(style);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gauge_ratio_is_clamped() {
assert_eq!(NebulaGauge::new(1.5).ratio(), 1.0);
assert_eq!(NebulaGauge::new(-1.0).ratio(), 0.0);
}
#[test]
fn effect_can_be_applied_to_a_buffer() {
let area = Rect::new(0, 0, 12, 4);
let mut buf = Buffer::empty(area);
AislingEffect::new(8).intensity(7).apply(area, &mut buf);
}
}