#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
use std::borrow::Cow;
use scrin::{
Color, Frame, Rect,
core::buffer::{Buffer, Cell},
effects::{EffectKind, EffectPlayer, LoaderKind, LoaderPlayer},
interaction::{
HitRegion, MouseCursor, ScrollRowHit, SelectableSpan, SelectionGroup, TextRange,
WidgetAction, WidgetId, WidgetRole, WidgetState, WidgetValue,
},
style::{Modifier, Style},
theme::{Theme, ThemeTokens},
widgets::{
Widget,
block::{Block, BorderStyle},
},
};
pub use scrin;
pub mod prelude {
pub use crate::{
Aisling, AislingEffect, AislingExt, AislingPalette, Align, Bordered, FlickerPanel, Gauge,
GlyphRain, List, NebulaGauge, NeonBorder, OrbField, Paragraph, PulseRing, Radar,
ScrinEffect, ScrinLoader, SignalPanel, Sparkline, SplitDirection, SplitPane, StatusBar,
StreamPanel, TabBar, Table, WaveType, Waveform, scrin,
};
pub use scrin::effects::{EffectKind, LoaderKind};
pub use scrin::interaction::{HitRegion, WidgetId, WidgetRole};
pub use scrin::theme::{Theme, ThemeTokens};
pub use scrin::widgets::Widget;
}
impl Default for AislingPalette {
fn default() -> Self {
Self::cypherpunk()
}
}
#[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 cypherpunk() -> Self {
Self {
low: Color::rgb(0, 230, 255),
mid: Color::rgb(0, 255, 136),
high: Color::rgb(240, 255, 255),
pulse: Color::rgb(255, 200, 0),
shadow: Color::rgb(8, 12, 20),
}
}
#[must_use]
pub const fn dream() -> Self {
Self {
low: Color::rgb(58, 160, 220),
mid: Color::rgb(120, 100, 180),
high: Color::rgb(220, 210, 170),
pulse: Color::rgb(0, 200, 200),
shadow: Color::rgb(12, 14, 24),
}
}
#[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, 170, 50),
mid: Color::rgb(255, 120, 30),
high: Color::rgb(255, 230, 140),
pulse: Color::rgb(255, 80, 60),
shadow: Color::rgb(24, 12, 8),
}
}
#[must_use]
pub const fn theme_tokens(self) -> ThemeTokens {
ThemeTokens::new(
self.shadow,
self.high,
self.low,
self.mid,
self.mid,
self.pulse,
self.pulse,
)
}
#[must_use]
pub const fn from_theme_tokens(tokens: ThemeTokens) -> Self {
Self {
low: tokens.dim,
mid: tokens.accent,
high: tokens.text,
pulse: tokens.warning,
shadow: tokens.panel,
}
}
#[must_use]
pub const fn theme(self) -> Theme {
Theme {
bg: self.shadow,
fg: self.high,
accent: self.mid,
accent_bright: self.low,
muted: self.low,
surface: self.shadow,
surface_bright: self.shadow,
error: self.pulse,
warning: self.pulse,
success: self.mid,
info: self.low,
border: self.low,
border_focus: self.pulse,
text_primary: self.high,
text_secondary: self.mid,
text_dim: self.low,
highlight_bg: self.mid,
highlight_fg: self.shadow,
glow: self.pulse,
}
}
#[must_use]
pub const fn from_theme(theme: Theme) -> Self {
Self {
low: theme.info,
mid: theme.accent,
high: theme.text_primary,
pulse: theme.glow,
shadow: theme.surface,
}
}
#[must_use]
pub fn block<'a>(self, title: &'a str) -> Block<'a> {
Block::new(title)
.with_borders(BorderStyle::Plain)
.with_theme_tokens(self.theme_tokens())
.with_border_color(self.low)
.with_inner_margin(Rect::ZERO)
}
fn lane(self, value: u64) -> Color {
match value % 4 {
0 => self.low,
1 => self.mid,
2 => self.high,
_ => self.pulse,
}
}
}
impl From<AislingPalette> for ThemeTokens {
fn from(value: AislingPalette) -> Self {
value.theme_tokens()
}
}
impl From<ThemeTokens> for AislingPalette {
fn from(value: ThemeTokens) -> Self {
Self::from_theme_tokens(value)
}
}
impl From<AislingPalette> for Theme {
fn from(value: AislingPalette) -> Self {
value.theme()
}
}
impl From<Theme> for AislingPalette {
fn from(value: Theme) -> Self {
Self::from_theme(value)
}
}
#[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 is_empty(area) || 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 {
if self.scanlines && (u64::from(y) + edge_phase).is_multiple_of(3) {
set_cell_bg(buf, x, y, self.palette.shadow);
}
if self.shimmer {
let phase = u64::from(x) * 3 + u64::from(y) * 5 + self.tick;
if phase % 11 >= shimmer_gate {
set_cell_style(
buf,
x,
y,
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
{
set_cell_style(
buf,
x,
y,
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, buf: &mut Buffer, area: Rect) {
self.inner.render(buf, area);
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)]
pub struct ScrinEffect<'a> {
kind: EffectKind,
text: Cow<'a, str>,
tick: u64,
duration: Option<usize>,
seed: Option<u64>,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for ScrinEffect<'_> {
fn eq(&self, other: &Self) -> bool {
self.kind == other.kind
&& self.text == other.text
&& self.tick == other.tick
&& self.duration == other.duration
&& self.seed == other.seed
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl<'a> ScrinEffect<'a> {
#[must_use]
pub fn new(kind: EffectKind, text: impl Into<Cow<'a, str>>) -> Self {
Self {
kind,
text: text.into(),
tick: 0,
duration: None,
seed: None,
palette: AislingPalette::cypherpunk(),
block: None,
}
}
#[must_use]
pub fn tick(mut self, tick: u64) -> Self {
self.tick = tick;
self
}
#[must_use]
pub fn duration(mut self, duration: usize) -> Self {
self.duration = Some(duration.max(1));
self
}
#[must_use]
pub fn seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
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
}
pub fn render_with_interaction(
&self,
frame: &mut Frame<'_>,
id: impl Into<WidgetId>,
area: Rect,
) {
let id = id.into();
self.render(frame.buffer(), area);
frame.register_hit_region(
HitRegion::new(id, area)
.with_role(WidgetRole::Effect)
.with_label(format!("{} effect", self.kind.name()))
.with_action(WidgetAction::Focus)
.with_value(WidgetValue::Status(self.kind.name().to_string())),
);
frame.mark_dirty(area);
}
fn player_for(&self, area: Rect) -> EffectPlayer {
let mut player = EffectPlayer::new(self.kind, self.text.as_ref())
.with_accent(self.palette.mid)
.with_size(
usize::from(area.width.max(1)),
usize::from(area.height.max(1)),
)
.with_gradient_colors(
vec![
self.palette.low,
self.palette.mid,
self.palette.high,
self.palette.pulse,
],
45.0,
);
if let Some(duration) = self.duration {
player = player.with_duration(duration);
}
if let Some(seed) = self.seed {
player = player.with_seed(seed);
}
let total = player.total_frames().max(1);
player.set_frame((self.tick as usize) % total);
player
}
}
impl Widget for ScrinEffect<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) {
return;
}
self.player_for(inner).render_to_buffer(buf, inner);
}
}
#[derive(Clone, Debug)]
pub struct ScrinLoader<'a> {
kind: LoaderKind,
progress: f32,
tick: u64,
label: Option<Cow<'a, str>>,
unit: Option<Cow<'a, str>>,
fraction: bool,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for ScrinLoader<'_> {
fn eq(&self, other: &Self) -> bool {
self.kind == other.kind
&& self.progress == other.progress
&& self.tick == other.tick
&& self.label == other.label
&& self.unit == other.unit
&& self.fraction == other.fraction
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl<'a> ScrinLoader<'a> {
#[must_use]
pub fn new(kind: LoaderKind, progress: f32) -> Self {
Self {
kind,
progress: progress.clamp(0.0, 1.0),
tick: 0,
label: None,
unit: None,
fraction: false,
palette: AislingPalette::cypherpunk(),
block: None,
}
}
#[must_use]
pub fn progress(&self) -> f32 {
self.progress
}
#[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 unit(mut self, unit: impl Into<Cow<'a, str>>) -> Self {
self.unit = Some(unit.into());
self
}
#[must_use]
pub fn fraction(mut self, fraction: bool) -> Self {
self.fraction = fraction;
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
}
pub fn render_with_interaction(
&self,
frame: &mut Frame<'_>,
id: impl Into<WidgetId>,
area: Rect,
) {
let id = id.into();
self.render(frame.buffer(), area);
frame.register_hit_region(
HitRegion::new(id, area)
.with_role(WidgetRole::StatusIndicator)
.with_label(self.label.as_deref().unwrap_or_else(|| self.kind.name()))
.with_action(WidgetAction::Focus)
.with_value(WidgetValue::Percent((self.progress * 100.0).round() as u16)),
);
frame.mark_dirty(area);
}
fn player_for(&self, area: Rect) -> LoaderPlayer {
let mut player = LoaderPlayer::new(self.kind)
.with_accent(self.palette.mid)
.with_size(
usize::from(area.width.max(1)),
usize::from(area.height.max(1)),
)
.with_fraction(self.fraction)
.with_gradient_colors(
vec![
self.palette.low,
self.palette.mid,
self.palette.high,
self.palette.pulse,
],
45.0,
);
if let Some(label) = &self.label {
player = player.with_label(label.to_string());
}
if let Some(unit) = &self.unit {
player = player.with_unit(unit.as_ref());
}
player
}
}
impl Widget for ScrinLoader<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) {
return;
}
self.player_for(inner).render(
self.tick as usize,
LoaderPlayer::progress_from_fraction(self.progress),
buf,
inner,
);
}
}
#[derive(Clone, Debug)]
pub struct GlyphRain<'a> {
tick: u64,
density: u16,
glyphs: Cow<'a, str>,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for GlyphRain<'_> {
fn eq(&self, other: &Self) -> bool {
self.tick == other.tick
&& self.density == other.density
&& self.glyphs == other.glyphs
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl Eq for GlyphRain<'_> {}
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, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) || 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 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))
};
set_styled_char(buf, x, y, glyph, style);
}
}
}
}
#[derive(Clone, Debug)]
pub struct NebulaGauge<'a> {
ratio: f64,
tick: u64,
label: Option<Cow<'a, str>>,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for NebulaGauge<'_> {
fn eq(&self, other: &Self) -> bool {
self.ratio == other.ratio
&& self.tick == other.tick
&& self.label == other.label
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
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, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) {
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;
if offset < filled {
set_styled_char(
buf,
x,
y,
'█',
Style::default()
.fg(self.palette.lane(flow))
.bg(self.palette.shadow)
.add_modifier(Modifier::BOLD),
);
} else {
set_styled_char(buf, x, y, '░', 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, buf: &mut Buffer, area: Rect) {
if is_empty(area) {
return;
}
let block = Block::new(self.title.as_ref())
.with_borders(BorderStyle::Plain)
.with_border_color(self.palette.mid)
.with_inner_margin(Rect::ZERO);
let inner = block_content_area(&block, area);
block.render(buf, area);
if is_empty(inner) {
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.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)
};
set_styled_char(buf, x, y, symbol, style);
}
}
}
}
#[derive(Clone, Debug)]
pub struct FlickerPanel<'a> {
text: Cow<'a, str>,
tick: u64,
intensity: u16,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for FlickerPanel<'_> {
fn eq(&self, other: &Self) -> bool {
self.text == other.text
&& self.tick == other.tick
&& self.intensity == other.intensity
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl Eq for FlickerPanel<'_> {}
impl<'a> FlickerPanel<'a> {
#[must_use]
pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
tick: 0,
intensity: 5,
palette: AislingPalette::dream(),
block: None,
}
}
#[must_use]
pub fn tick(mut self, tick: u64) -> Self {
self.tick = tick;
self
}
#[must_use]
pub fn intensity(mut self, intensity: u16) -> Self {
self.intensity = intensity.min(10);
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 FlickerPanel<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) || self.intensity == 0 {
return;
}
let glitch_chars: Vec<char> = "░▒▓█▀▄▌▐│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬".chars().collect();
let text_chars: Vec<char> = self.text.chars().collect();
if text_chars.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 col = usize::from(x.saturating_sub(inner.x));
let noise = field_noise(x, y, self.tick);
let glitch_gate = 11_u64.saturating_sub(u64::from(self.intensity));
let (ch, style) = if noise % 11 >= glitch_gate {
let g = glitch_chars[(noise as usize) % glitch_chars.len()];
(
g,
Style::default()
.fg(self.palette.pulse)
.add_modifier(Modifier::BOLD),
)
} else if col < text_chars.len() {
let c = text_chars[col];
let flicker = (noise + self.tick) % 9 == 0;
let style = if flicker {
Style::default()
.fg(self.palette.high)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.palette.mid)
};
(c, style)
} else {
(' ', Style::default())
};
set_styled_char(buf, x, y, ch, style);
}
}
}
}
#[derive(Clone, Debug)]
pub struct Waveform<'a> {
tick: u64,
frequency: f64,
amplitude: f64,
wave_type: WaveType,
palette: AislingPalette,
block: Option<Block<'a>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WaveType {
Sine,
Square,
Sawtooth,
Triangle,
}
impl PartialEq for Waveform<'_> {
fn eq(&self, other: &Self) -> bool {
self.tick == other.tick
&& self.frequency == other.frequency
&& self.amplitude == other.amplitude
&& self.wave_type == other.wave_type
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl Eq for Waveform<'_> {}
impl<'a> Waveform<'a> {
#[must_use]
pub fn new(frequency: f64, amplitude: f64) -> Self {
Self {
tick: 0,
frequency,
amplitude: amplitude.clamp(0.0, 1.0),
wave_type: WaveType::Sine,
palette: AislingPalette::phosphor(),
block: None,
}
}
#[must_use]
pub fn tick(mut self, tick: u64) -> Self {
self.tick = tick;
self
}
#[must_use]
pub fn wave_type(mut self, wave_type: WaveType) -> Self {
self.wave_type = wave_type;
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
}
fn sample(&self, phase: f64) -> f64 {
let t = phase.fract();
match self.wave_type {
WaveType::Sine => (std::f64::consts::TAU * t).sin(),
WaveType::Square => {
if t < 0.5 {
1.0
} else {
-1.0
}
}
WaveType::Sawtooth => 2.0 * t - 1.0,
WaveType::Triangle => {
if t < 0.5 {
4.0 * t - 1.0
} else {
3.0 - 4.0 * t
}
}
}
}
}
impl Widget for Waveform<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) || inner.height < 3 {
return;
}
let mid_y = inner.y + inner.height / 2;
let half = (inner.height / 2) as f64;
for col in 0..inner.width {
let phase = f64::from(col) / f64::from(inner.width) * self.frequency
+ f64::from(self.tick as u32) * 0.05;
let sample = self.sample(phase);
let offset = (sample * self.amplitude * half).round() as i16;
let y = mid_y as i16 + offset;
if y >= inner.y as i16 && y < (inner.y + inner.height) as i16 {
let noise = field_noise(inner.x + col, y as u16, self.tick);
set_styled_char(
buf,
inner.x + col,
y as u16,
'█',
Style::default()
.fg(self.palette.lane(noise))
.add_modifier(Modifier::BOLD),
);
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PulseRing {
tick: u64,
rings: u16,
palette: AislingPalette,
}
impl PulseRing {
#[must_use]
pub fn new(rings: u16) -> Self {
Self {
tick: 0,
rings: rings.max(1),
palette: AislingPalette::dream(),
}
}
#[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 PulseRing {
fn render(&self, buf: &mut Buffer, area: Rect) {
if is_empty(area) || self.rings == 0 {
return;
}
let cx = area.x + area.width / 2;
let cy = area.y + area.height / 2;
let max_radius = (area.width.min(area.height) / 2) as f64;
if max_radius < 1.0 {
return;
}
let right = area.x.saturating_add(area.width);
let bottom = area.y.saturating_add(area.height);
for y in area.y..bottom {
for x in area.x..right {
let dx = x as f64 - cx as f64;
let dy = y as f64 - cy as f64;
let dist = (dx * dx + dy * dy).sqrt();
for ring in 0..self.rings {
let ring_phase = (self.tick as f64 * 0.1 + ring as f64 * 3.0) % max_radius;
let diff = (dist - ring_phase).abs();
if diff < 1.5 {
let noise = field_noise(x, y, self.tick + ring as u64);
let style = if diff < 0.8 {
Style::default()
.fg(self.palette.high)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.palette.lane(noise))
};
set_styled_char(buf, x, y, '○', style);
break;
}
}
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Radar {
tick: u64,
sweep_speed: u64,
palette: AislingPalette,
}
impl Radar {
#[must_use]
pub fn new(sweep_speed: u64) -> Self {
Self {
tick: 0,
sweep_speed: sweep_speed.max(1),
palette: AislingPalette::phosphor(),
}
}
#[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 Radar {
fn render(&self, buf: &mut Buffer, area: Rect) {
if is_empty(area) {
return;
}
let cx = area.x + area.width / 2;
let cy = area.y + area.height / 2;
let max_r = (area.width.min(area.height) / 2) as f64;
if max_r < 1.0 {
return;
}
let sweep_angle = (self.tick as f64 / self.sweep_speed as f64) % (std::f64::consts::TAU);
let trail_len = std::f64::consts::PI * 0.6;
let right = area.x.saturating_add(area.width);
let bottom = area.y.saturating_add(area.height);
for y in area.y..bottom {
for x in area.x..right {
let dx = x as f64 - cx as f64;
let dy = y as f64 - cy as f64;
let dist = (dx * dx + dy * dy).sqrt();
if dist > max_r {
continue;
}
let angle = dy.atan2(dx);
let norm_angle = if angle < 0.0 {
angle + std::f64::consts::TAU
} else {
angle
};
let ring = dist as u16;
if ring > 0 && dist % (max_r / 3.0) < 0.5 {
set_styled_char(buf, x, y, '·', Style::default().fg(self.palette.shadow));
continue;
}
let diff = (norm_angle - sweep_angle).abs();
let diff = if diff > std::f64::consts::PI {
std::f64::consts::TAU - diff
} else {
diff
};
if diff < trail_len {
let fade = 1.0 - diff / trail_len;
let noise = field_noise(x, y, self.tick);
let color = if fade > 0.6 {
self.palette.high
} else {
self.palette.lane(noise)
};
set_styled_char(
buf,
x,
y,
if dist < 1.0 { '●' } else { '·' },
Style::default().fg(color).add_modifier(Modifier::BOLD),
);
} else if (dist - 1.0).abs() < 0.5
|| (dist - max_r * 0.5).abs() < 0.5
|| (dist - max_r * 0.9).abs() < 0.5
{
set_styled_char(buf, x, y, '·', Style::default().fg(self.palette.shadow));
}
}
}
}
}
#[derive(Clone, Debug)]
pub struct OrbField<'a> {
tick: u64,
count: u16,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for OrbField<'_> {
fn eq(&self, other: &Self) -> bool {
self.tick == other.tick
&& self.count == other.count
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl Eq for OrbField<'_> {}
impl<'a> OrbField<'a> {
#[must_use]
pub fn new(count: u16) -> Self {
Self {
tick: 0,
count,
palette: AislingPalette::dream(),
block: None,
}
}
#[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 block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
}
impl Widget for OrbField<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) || self.count == 0 {
return;
}
for i in 0..u64::from(self.count) {
let seed = field_noise(i as u16, i as u16 / 7, i);
let speed_x = (seed % 7) as f64 * 0.3 + 0.2;
let speed_y = ((seed >> 3) % 5) as f64 * 0.2 + 0.1;
let phase_x = seed as f64 * 0.1;
let phase_y = (seed >> 5) as f64 * 0.13;
let base_x = (inner.x as f64)
+ ((self.tick as f64 * speed_x * 0.02 + phase_x).sin() * 0.5 + 0.5)
* inner.width as f64;
let base_y = (inner.y as f64)
+ ((self.tick as f64 * speed_y * 0.02 + phase_y).cos() * 0.5 + 0.5)
* inner.height as f64;
let px = base_x.round() as u16;
let py = base_y.round() as u16;
if px >= inner.x
&& px < inner.x + inner.width
&& py >= inner.y
&& py < inner.y + inner.height
{
let noise = field_noise(px, py, self.tick + i);
let glyph = if noise % 3 == 0 {
'◆'
} else if noise % 3 == 1 {
'◇'
} else {
'•'
};
set_styled_char(
buf,
px,
py,
glyph,
Style::default()
.fg(self.palette.lane(i))
.add_modifier(Modifier::BOLD),
);
}
}
}
}
#[derive(Clone, Debug)]
pub struct NeonBorder<'a> {
tick: u64,
speed: u64,
palette: AislingPalette,
inner: Block<'a>,
}
impl PartialEq for NeonBorder<'_> {
fn eq(&self, other: &Self) -> bool {
self.tick == other.tick
&& self.speed == other.speed
&& self.palette == other.palette
&& block_eq(&self.inner, &other.inner)
}
}
impl<'a> NeonBorder<'a> {
#[must_use]
pub fn new(inner: Block<'a>) -> Self {
Self {
tick: 0,
speed: 3,
palette: AislingPalette::dream(),
inner,
}
}
#[must_use]
pub fn tick(mut self, tick: u64) -> Self {
self.tick = tick;
self
}
#[must_use]
pub fn speed(mut self, speed: u64) -> Self {
self.speed = speed.max(1);
self
}
#[must_use]
pub fn palette(mut self, palette: AislingPalette) -> Self {
self.palette = palette;
self
}
pub fn render_border(&self, buf: &mut Buffer, area: Rect) -> Rect {
let inner = block_content_area(&self.inner, area);
let right = area.x.saturating_add(area.width);
let bottom = area.y.saturating_add(area.height);
let perimeter = 2 * (area.width + area.height) as u64;
for y in area.y..bottom {
for x in area.x..right {
if !is_edge(area, x, y) {
continue;
}
let pos = if y == area.y {
u64::from(x - area.x)
} else if x + 1 == right {
u64::from(area.width) + u64::from(y - area.y)
} else if y + 1 == bottom {
u64::from(area.width + area.height) + u64::from(right - x - 1)
} else {
u64::from(2 * area.width + area.height) + u64::from(bottom - y - 1)
};
let phase = (self.tick * self.speed + pos) % perimeter;
let color_idx = (phase * 4 / perimeter) as usize;
let color = match color_idx {
0 => self.palette.low,
1 => self.palette.mid,
2 => self.palette.high,
_ => self.palette.pulse,
};
let ch = if y == area.y || y + 1 == bottom {
'─'
} else {
'│'
};
set_styled_char(
buf,
x,
y,
ch,
Style::default().fg(color).add_modifier(Modifier::BOLD),
);
}
}
inner
}
}
impl Widget for NeonBorder<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
self.render_border(buf, area);
}
}
#[derive(Clone, Debug)]
pub struct StreamPanel<'a> {
lines: Vec<Cow<'a, str>>,
scroll_offset: u16,
follow_tail: bool,
show_line_numbers: bool,
tick: u64,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for StreamPanel<'_> {
fn eq(&self, other: &Self) -> bool {
self.lines == other.lines
&& self.scroll_offset == other.scroll_offset
&& self.follow_tail == other.follow_tail
&& self.show_line_numbers == other.show_line_numbers
&& self.tick == other.tick
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl<'a> StreamPanel<'a> {
#[must_use]
pub fn new() -> Self {
Self {
lines: Vec::new(),
scroll_offset: 0,
follow_tail: true,
show_line_numbers: false,
tick: 0,
palette: AislingPalette::phosphor(),
block: None,
}
}
#[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 push_line(mut self, line: impl Into<Cow<'a, str>>) -> Self {
self.lines.push(line.into());
self
}
#[must_use]
pub fn scroll_offset(mut self, offset: u16) -> Self {
self.scroll_offset = offset;
self
}
#[must_use]
pub fn follow_tail(mut self, follow: bool) -> Self {
self.follow_tail = follow;
self
}
#[must_use]
pub fn show_line_numbers(mut self, show: bool) -> Self {
self.show_line_numbers = show;
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
}
#[must_use]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn render_with_interaction(
&self,
frame: &mut Frame<'_>,
id: impl Into<WidgetId>,
area: Rect,
) {
let id = id.into();
self.render(frame.buffer(), area);
for region in self.hit_regions(id.clone(), area) {
frame.register_hit_region(region);
}
for span in self.selectable_spans(id.clone(), area) {
frame.register_selectable_span(span);
}
if let Some((viewport, start, rows)) = self.scroll_region(id.clone(), area) {
frame.register_scroll_region(id, viewport, start, rows);
}
frame.mark_dirty(area);
}
#[must_use]
pub fn hit_regions(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<HitRegion> {
let region_id = id.into();
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if is_empty(area) || is_empty(inner) {
return Vec::new();
}
let mut regions = vec![
HitRegion::new(region_id.clone(), area)
.with_role(WidgetRole::Transcript)
.with_label("stream")
.with_value(WidgetValue::Count(self.lines.len())),
];
let start = self.visible_start(inner.height);
for row in 0..inner.height {
let line_idx = start + row as usize;
if line_idx >= self.lines.len() {
break;
}
let row_area = Rect::new(inner.x, inner.y + row, inner.width, 1);
regions.push(
HitRegion::new(format!("{}:line:{line_idx}", region_id.as_ref()), row_area)
.with_role(WidgetRole::TranscriptRow)
.with_label(self.lines[line_idx].as_ref())
.with_action(WidgetAction::Select)
.with_cursor(MouseCursor::Text)
.with_row(line_idx)
.with_value(WidgetValue::LineNumber(line_idx + 1))
.with_z_index(1),
);
}
regions
}
#[must_use]
pub fn selectable_spans(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<SelectableSpan> {
let region_id = id.into();
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if is_empty(inner) {
return Vec::new();
}
let gutter_width = self.gutter_width();
let text_width = inner.width.saturating_sub(gutter_width);
if text_width == 0 {
return Vec::new();
}
let group = SelectionGroup::new(format!("{}:lines", region_id.as_ref()));
let start = self.visible_start(inner.height);
let mut spans = Vec::new();
for row in 0..inner.height {
let line_idx = start + row as usize;
if line_idx >= self.lines.len() {
break;
}
let text = clipped_text(self.lines[line_idx].as_ref(), text_width);
if text.is_empty() {
continue;
}
let width = text.chars().count().min(usize::from(text_width)) as u16;
spans.push(
SelectableSpan::from_logical(
format!("{}:span:{line_idx}", region_id.as_ref()),
region_id.clone(),
Rect::new(inner.x + gutter_width, inner.y + row, width, 1),
TextRange::new(line_idx, 0, width as usize),
text,
)
.with_group(group.clone()),
);
}
spans
}
#[must_use]
pub fn scroll_region(
&self,
id: impl Into<WidgetId>,
area: Rect,
) -> Option<(Rect, usize, Vec<ScrollRowHit>)> {
let region_id = id.into();
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if is_empty(inner) {
return None;
}
let start = self.visible_start(inner.height);
let mut rows = Vec::new();
for row in 0..inner.height {
let line_idx = start + row as usize;
if line_idx >= self.lines.len() {
break;
}
let row_id = WidgetId::new(format!("{}:line:{line_idx}", region_id.as_ref()));
rows.push(
ScrollRowHit::new(row_id.clone(), line_idx)
.with_source_line(line_idx)
.with_span_id(format!("{}:span:{line_idx}", region_id.as_ref()))
.with_item_id(row_id),
);
}
Some((inner, start, rows))
}
#[must_use]
pub fn line_count(&self) -> usize {
self.lines.len()
}
fn gutter_width(&self) -> u16 {
if self.show_line_numbers {
let max_num = self.lines.len().max(1);
let digits = format!("{max_num}").len() as u16;
digits + 1
} else {
0
}
}
fn visible_start(&self, visible_height: u16) -> usize {
let total = self.lines.len() as u16;
if self.follow_tail || self.scroll_offset == 0 {
let shown = visible_height.min(total);
(total - shown) as usize
} else {
let max_top = total.saturating_sub(visible_height);
(max_top.saturating_sub(self.scroll_offset)) as usize
}
}
}
impl Default for StreamPanel<'_> {
fn default() -> Self {
Self::new()
}
}
impl Widget for StreamPanel<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) {
return;
}
let gutter_width = self.gutter_width();
let text_width = inner.width.saturating_sub(gutter_width);
if text_width == 0 {
return;
}
let start = self.visible_start(inner.height);
let right = inner.x.saturating_add(inner.width);
let total = self.lines.len();
for row in 0..inner.height {
let line_idx = start + row as usize;
let y = inner.y + row;
if line_idx >= total {
break;
}
if self.show_line_numbers {
let num_str = format!(
"{:>width$}",
line_idx + 1,
width = (gutter_width - 1) as usize
);
paint_text(
Rect::new(inner.x, y, gutter_width.saturating_sub(1), 1),
buf,
&num_str,
Style::default().fg(self.palette.shadow),
);
set_styled_char(
buf,
inner.x + gutter_width - 1,
y,
'│',
Style::default().fg(self.palette.shadow),
);
}
let line = &self.lines[line_idx];
let text_chars: Vec<char> = line.chars().collect();
for col in 0..text_width {
let x = inner.x + gutter_width + col;
if x >= right {
break;
}
let ch = text_chars.get(col as usize).copied().unwrap_or(' ');
let noise = field_noise(x, y, self.tick);
let style = if ch == ' ' {
Style::default()
} else if (noise + self.tick) % 31 == 0 {
Style::default()
.fg(self.palette.pulse)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.palette.high)
};
set_styled_char(buf, x, y, ch, style);
}
}
if !self.follow_tail && self.scroll_offset > 0 {
let indicator_y = inner.y;
let indicator_style = Style::default()
.fg(self.palette.pulse)
.add_modifier(Modifier::BOLD);
if inner.width > 2 {
set_styled_char(buf, right - 2, indicator_y, '▲', indicator_style);
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SplitDirection {
Horizontal,
Vertical,
}
pub struct SplitPane {
ratio: f64,
direction: SplitDirection,
divider: Option<char>,
}
impl SplitPane {
#[must_use]
pub fn horizontal() -> Self {
Self {
ratio: 0.5,
direction: SplitDirection::Horizontal,
divider: None,
}
}
#[must_use]
pub fn vertical() -> Self {
Self {
ratio: 0.5,
direction: SplitDirection::Vertical,
divider: None,
}
}
#[must_use]
pub fn ratio(mut self, ratio: f64) -> Self {
self.ratio = ratio.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn divider(mut self, divider: char) -> Self {
self.divider = Some(divider);
self
}
pub fn split(&self, area: Rect) -> (Rect, Rect, Rect) {
if is_empty(area) {
return (Rect::ZERO, Rect::ZERO, Rect::ZERO);
}
match self.direction {
SplitDirection::Vertical => {
let has_divider = self.divider.is_some() && area.width > 1;
let available = if has_divider {
area.width.saturating_sub(1)
} else {
area.width
};
let first_width = (f64::from(available) * self.ratio).round() as u16;
let second_width = available.saturating_sub(first_width);
let a = Rect::new(area.x, area.y, first_width, area.height);
let div = if has_divider {
Rect::new(area.x + first_width, area.y, 1, area.height)
} else {
Rect::ZERO
};
let b_x = area.x + first_width + if has_divider { 1 } else { 0 };
let b = Rect::new(b_x, area.y, second_width, area.height);
(a, b, div)
}
SplitDirection::Horizontal => {
let has_divider = self.divider.is_some() && area.height > 1;
let available = if has_divider {
area.height.saturating_sub(1)
} else {
area.height
};
let first_height = (f64::from(available) * self.ratio).round() as u16;
let second_height = available.saturating_sub(first_height);
let a = Rect::new(area.x, area.y, area.width, first_height);
let div = if has_divider {
Rect::new(area.x, area.y + first_height, area.width, 1)
} else {
Rect::ZERO
};
let b_y = area.y + first_height + if has_divider { 1 } else { 0 };
let b = Rect::new(area.x, b_y, area.width, second_height);
(a, b, div)
}
}
}
pub fn render_divider(&self, buf: &mut Buffer, divider_area: Rect, palette: AislingPalette) {
if is_empty(divider_area) {
return;
}
let ch = self.divider.unwrap_or(' ');
let style = Style::default().fg(palette.mid);
for y in divider_area.y..divider_area.y.saturating_add(divider_area.height) {
for x in divider_area.x..divider_area.x.saturating_add(divider_area.width) {
set_styled_char(buf, x, y, ch, style);
}
}
}
}
#[derive(Clone, Debug)]
pub struct List<'a> {
items: Vec<Cow<'a, str>>,
selected: Option<usize>,
scroll_offset: u16,
tick: u64,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for List<'_> {
fn eq(&self, other: &Self) -> bool {
self.items == other.items
&& self.selected == other.selected
&& self.scroll_offset == other.scroll_offset
&& self.tick == other.tick
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl<'a> List<'a> {
#[must_use]
pub fn new() -> Self {
Self {
items: Vec::new(),
selected: None,
scroll_offset: 0,
tick: 0,
palette: AislingPalette::cypherpunk(),
block: None,
}
}
#[must_use]
pub fn item(mut self, item: impl Into<Cow<'a, str>>) -> Self {
self.items.push(item.into());
self
}
#[must_use]
pub fn items<I, S>(mut self, items: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Cow<'a, str>>,
{
self.items = items.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn selected(mut self, index: Option<usize>) -> Self {
self.selected = index;
self
}
#[must_use]
pub fn scroll_offset(mut self, offset: u16) -> Self {
self.scroll_offset = offset;
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
}
#[must_use]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn render_with_interaction(
&self,
frame: &mut Frame<'_>,
id: impl Into<WidgetId>,
area: Rect,
) {
let id = id.into();
self.render(frame.buffer(), area);
for region in self.hit_regions(id.clone(), area) {
frame.register_hit_region(region);
}
for span in self.selectable_spans(id.clone(), area) {
frame.register_selectable_span(span);
}
if let Some((viewport, start, rows)) = self.scroll_region(id.clone(), area) {
frame.register_scroll_region(id, viewport, start, rows);
}
frame.mark_dirty(area);
}
#[must_use]
pub fn hit_regions(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<HitRegion> {
let region_id = id.into();
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if is_empty(area) || is_empty(inner) {
return Vec::new();
}
let mut regions = vec![
HitRegion::new(region_id.clone(), area)
.with_role(WidgetRole::Region)
.with_label("list")
.with_value(WidgetValue::Count(self.items.len())),
];
let group = SelectionGroup::new(format!("{}:items", region_id.as_ref()));
let start = self.visible_start(inner.height);
for row in 0..inner.height {
let idx = start + row as usize;
if idx >= self.items.len() {
break;
}
let selected = self.selected == Some(idx);
let row_area = Rect::new(inner.x, inner.y + row, inner.width, 1);
regions.push(
HitRegion::new(format!("{}:item:{idx}", region_id.as_ref()), row_area)
.with_role(WidgetRole::ListItem)
.with_label(self.items[idx].as_ref())
.with_action(WidgetAction::Focus)
.with_cursor(MouseCursor::Pointer)
.with_row(idx)
.with_selection_group(group.clone())
.with_state(WidgetState::default().selected(selected))
.with_z_index(1),
);
}
regions
}
#[must_use]
pub fn selectable_spans(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<SelectableSpan> {
let region_id = id.into();
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if is_empty(inner) {
return Vec::new();
}
let text_x = inner.x.saturating_add(2);
let text_width = inner.width.saturating_sub(2);
if text_width == 0 {
return Vec::new();
}
let group = SelectionGroup::new(format!("{}:items", region_id.as_ref()));
let start = self.visible_start(inner.height);
let mut spans = Vec::new();
for row in 0..inner.height {
let item_idx = start + row as usize;
if item_idx >= self.items.len() {
break;
}
let text = clipped_text(self.items[item_idx].as_ref(), text_width);
if text.is_empty() {
continue;
}
let width = text.chars().count().min(usize::from(text_width)) as u16;
spans.push(
SelectableSpan::from_logical(
format!("{}:span:{item_idx}", region_id.as_ref()),
region_id.clone(),
Rect::new(text_x, inner.y + row, width, 1),
TextRange::new(item_idx, 0, width as usize),
text,
)
.with_group(group.clone()),
);
}
spans
}
#[must_use]
pub fn scroll_region(
&self,
id: impl Into<WidgetId>,
area: Rect,
) -> Option<(Rect, usize, Vec<ScrollRowHit>)> {
let region_id = id.into();
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if is_empty(inner) {
return None;
}
let start = self.visible_start(inner.height);
let mut rows = Vec::new();
for row in 0..inner.height {
let item_idx = start + row as usize;
if item_idx >= self.items.len() {
break;
}
let row_id = WidgetId::new(format!("{}:item:{item_idx}", region_id.as_ref()));
rows.push(
ScrollRowHit::new(row_id.clone(), item_idx)
.with_span_id(format!("{}:span:{item_idx}", region_id.as_ref()))
.with_item_id(row_id),
);
}
Some((inner, start, rows))
}
#[must_use]
pub fn item_count(&self) -> usize {
self.items.len()
}
fn visible_start(&self, visible_height: u16) -> usize {
let total = self.items.len() as u16;
if let Some(sel) = self.selected {
let sel = sel as u16;
if sel < self.scroll_offset {
return sel as usize;
}
if sel >= self.scroll_offset + visible_height {
return (sel + 1 - visible_height) as usize;
}
return self.scroll_offset as usize;
}
let max_top = total.saturating_sub(visible_height);
(self.scroll_offset.min(max_top)) as usize
}
}
impl Default for List<'_> {
fn default() -> Self {
Self::new()
}
}
impl Widget for List<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) {
return;
}
let start = self.visible_start(inner.height);
let indicator_width = 2u16;
let text_width = inner.width.saturating_sub(indicator_width);
for row in 0..inner.height {
let idx = start + row as usize;
let y = inner.y + row;
if idx >= self.items.len() {
break;
}
let is_selected = self.selected == Some(idx);
let indicator = if is_selected { "▸ " } else { " " };
let indicator_style = if is_selected {
Style::default()
.fg(self.palette.pulse)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.palette.shadow)
};
paint_text(
Rect::new(inner.x, y, indicator_width, 1),
buf,
indicator,
indicator_style,
);
let item = &self.items[idx];
let item_chars: Vec<char> = item.chars().collect();
for col in 0..text_width {
let x = inner.x + indicator_width + col;
let ch = item_chars.get(col as usize).copied().unwrap_or(' ');
let style = if is_selected {
if ch == ' ' {
Style::default().bg(self.palette.shadow)
} else {
Style::default()
.fg(self.palette.high)
.bg(self.palette.shadow)
.add_modifier(Modifier::BOLD)
}
} else if ch == ' ' {
Style::default()
} else {
Style::default().fg(self.palette.high)
};
set_styled_char(buf, x, y, ch, style);
}
}
}
}
#[derive(Clone, Debug)]
pub struct TabBar<'a> {
tabs: Vec<Cow<'a, str>>,
selected: usize,
tick: u64,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for TabBar<'_> {
fn eq(&self, other: &Self) -> bool {
self.tabs == other.tabs
&& self.selected == other.selected
&& self.tick == other.tick
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl<'a> TabBar<'a> {
#[must_use]
pub fn new<I, S>(tabs: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Cow<'a, str>>,
{
Self {
tabs: tabs.into_iter().map(Into::into).collect(),
selected: 0,
tick: 0,
palette: AislingPalette::cypherpunk(),
block: None,
}
}
#[must_use]
pub fn selected(mut self, index: usize) -> Self {
self.selected = index;
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
}
#[must_use]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn render_with_interaction(
&self,
frame: &mut Frame<'_>,
id: impl Into<WidgetId>,
area: Rect,
) {
let id = id.into();
self.render(frame.buffer(), area);
for region in self.hit_regions(id.clone(), area) {
frame.register_hit_region(region);
}
frame.mark_dirty(area);
}
#[must_use]
pub fn hit_regions(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<HitRegion> {
let region_id = id.into();
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if is_empty(area) || is_empty(inner) {
return Vec::new();
}
let mut regions = vec![
HitRegion::new(region_id.clone(), area)
.with_role(WidgetRole::Region)
.with_label("tabs")
.with_value(WidgetValue::Count(self.tabs.len())),
];
let mut x = inner.x;
let right = inner.x.saturating_add(inner.width);
for (idx, tab) in self.tabs.iter().enumerate() {
if x >= right {
break;
}
let label_width = tab.chars().count() as u16;
let tab_width = label_width.saturating_add(4).min(right - x);
if tab_width == 0 {
break;
}
regions.push(
HitRegion::new(
format!("{}:tab:{idx}", region_id.as_ref()),
Rect::new(x, inner.y, tab_width, 1),
)
.with_role(WidgetRole::Tab)
.with_label(tab.as_ref())
.with_action(WidgetAction::Focus)
.with_cursor(MouseCursor::Pointer)
.with_row(idx)
.with_shortcut(format!("{}", idx + 1))
.with_state(WidgetState::default().selected(idx == self.selected))
.with_z_index(1),
);
x = x.saturating_add(tab_width);
}
regions
}
#[must_use]
pub fn tab_count(&self) -> usize {
self.tabs.len()
}
}
impl Widget for TabBar<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) {
return;
}
let mut x = inner.x;
let right = inner.x.saturating_add(inner.width);
for (i, tab) in self.tabs.iter().enumerate() {
if x >= right {
break;
}
let is_selected = i == self.selected;
let label: Vec<char> = tab.chars().collect();
let padding = 2u16;
let tab_width = (label.len() as u16 + padding * 2).min(right - x);
if is_selected {
set_styled_char(
buf,
x,
inner.y,
'㎍',
Style::default()
.fg(self.palette.pulse)
.add_modifier(Modifier::BOLD),
);
} else {
set_styled_char(buf, x, inner.y, ' ', Style::default());
}
for col in 0..tab_width {
let cx = x + col;
if cx >= right {
break;
}
let char_idx = col.saturating_sub(padding) as usize;
let ch = if col < padding || col >= tab_width - padding {
' '
} else if char_idx < label.len() {
label[char_idx]
} else {
' '
};
let style = if is_selected {
Style::default()
.fg(self.palette.high)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.palette.mid)
};
set_styled_char(buf, cx, inner.y, ch, style);
}
if is_selected {
let bottom = inner.y + inner.height.saturating_sub(1);
for col in 0..tab_width {
let cx = x + col;
if cx >= right {
break;
}
set_styled_char(
buf,
cx,
bottom,
'─',
Style::default().fg(self.palette.pulse),
);
}
}
x += tab_width;
}
}
}
#[derive(Clone, Debug)]
pub struct Table<'a> {
headers: Vec<Cow<'a, str>>,
rows: Vec<Vec<Cow<'a, str>>>,
widths: Option<Vec<u16>>,
selected: Option<usize>,
scroll_offset: u16,
tick: u64,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for Table<'_> {
fn eq(&self, other: &Self) -> bool {
self.headers == other.headers
&& self.rows == other.rows
&& self.widths == other.widths
&& self.selected == other.selected
&& self.scroll_offset == other.scroll_offset
&& self.tick == other.tick
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl<'a> Table<'a> {
#[must_use]
pub fn new<I, S>(headers: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Cow<'a, str>>,
{
Self {
headers: headers.into_iter().map(Into::into).collect(),
rows: Vec::new(),
widths: None,
selected: None,
scroll_offset: 0,
tick: 0,
palette: AislingPalette::cypherpunk(),
block: None,
}
}
#[must_use]
pub fn row<I, S>(mut self, row: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Cow<'a, str>>,
{
self.rows.push(row.into_iter().map(Into::into).collect());
self
}
#[must_use]
pub fn rows<I, R, S>(mut self, rows: I) -> Self
where
I: IntoIterator<Item = R>,
R: IntoIterator<Item = S>,
S: Into<Cow<'a, str>>,
{
self.rows = rows
.into_iter()
.map(|r| r.into_iter().map(Into::into).collect())
.collect();
self
}
#[must_use]
pub fn widths(mut self, widths: Vec<u16>) -> Self {
self.widths = Some(widths);
self
}
#[must_use]
pub fn selected(mut self, index: Option<usize>) -> Self {
self.selected = index;
self
}
#[must_use]
pub fn scroll_offset(mut self, offset: u16) -> Self {
self.scroll_offset = offset;
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
}
#[must_use]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn render_with_interaction(
&self,
frame: &mut Frame<'_>,
id: impl Into<WidgetId>,
area: Rect,
) {
let id = id.into();
self.render(frame.buffer(), area);
for region in self.hit_regions(id.clone(), area) {
frame.register_hit_region(region);
}
for span in self.selectable_spans(id.clone(), area) {
frame.register_selectable_span(span);
}
if let Some((viewport, start, rows)) = self.scroll_region(id.clone(), area) {
frame.register_scroll_region(id, viewport, start, rows);
}
frame.mark_dirty(area);
}
#[must_use]
pub fn hit_regions(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<HitRegion> {
let region_id = id.into();
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if is_empty(area) || is_empty(inner) {
return Vec::new();
}
let mut regions = vec![
HitRegion::new(region_id.clone(), area)
.with_role(WidgetRole::Region)
.with_label("table")
.with_value(WidgetValue::Count(self.rows.len())),
];
if self.headers.is_empty() || inner.height < 3 {
return regions;
}
let visible_rows = inner.height.saturating_sub(2);
let start = self
.scroll_offset
.min((self.rows.len() as u16).saturating_sub(visible_rows.min(self.rows.len() as u16)));
for row_offset in 0..visible_rows {
let row_idx = start as usize + row_offset as usize;
if row_idx >= self.rows.len() {
break;
}
let y = inner.y + 2 + row_offset;
let selected = self.selected == Some(row_idx);
let label = self.rows[row_idx]
.iter()
.map(Cow::as_ref)
.collect::<Vec<_>>()
.join(" | ");
regions.push(
HitRegion::new(
format!("{}:row:{row_idx}", region_id.as_ref()),
Rect::new(inner.x, y, inner.width, 1),
)
.with_role(WidgetRole::ModelRow)
.with_label(label)
.with_action(WidgetAction::Focus)
.with_cursor(MouseCursor::Pointer)
.with_row(row_idx)
.with_state(WidgetState::default().selected(selected))
.with_value(WidgetValue::Count(self.rows[row_idx].len()))
.with_z_index(1),
);
}
regions
}
#[must_use]
pub fn selectable_spans(&self, id: impl Into<WidgetId>, area: Rect) -> Vec<SelectableSpan> {
let region_id = id.into();
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
let Some((viewport, start, _)) = self.scroll_region(region_id.clone(), area) else {
return Vec::new();
};
if self.headers.is_empty() || is_empty(inner) || is_empty(viewport) {
return Vec::new();
}
let group = SelectionGroup::new(format!("{}:rows", region_id.as_ref()));
let mut spans = Vec::new();
for row_offset in 0..viewport.height {
let row_idx = start + row_offset as usize;
if row_idx >= self.rows.len() {
break;
}
let text = clipped_text(&self.row_label(row_idx), viewport.width);
if text.is_empty() {
continue;
}
let width = text.chars().count().min(usize::from(viewport.width)) as u16;
spans.push(
SelectableSpan::from_logical(
format!("{}:span:{row_idx}", region_id.as_ref()),
region_id.clone(),
Rect::new(viewport.x, viewport.y + row_offset, width, 1),
TextRange::new(row_idx, 0, width as usize),
text,
)
.with_group(group.clone()),
);
}
spans
}
#[must_use]
pub fn scroll_region(
&self,
id: impl Into<WidgetId>,
area: Rect,
) -> Option<(Rect, usize, Vec<ScrollRowHit>)> {
let region_id = id.into();
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if self.headers.is_empty() || inner.height < 3 || is_empty(inner) {
return None;
}
let visible_rows = inner.height.saturating_sub(2);
let viewport = Rect::new(inner.x, inner.y + 2, inner.width, visible_rows);
if is_empty(viewport) {
return None;
}
let total = self.rows.len() as u16;
let start = self
.scroll_offset
.min(total.saturating_sub(visible_rows.min(total))) as usize;
let mut rows = Vec::new();
for row_offset in 0..visible_rows {
let row_idx = start + row_offset as usize;
if row_idx >= self.rows.len() {
break;
}
let row_id = WidgetId::new(format!("{}:row:{row_idx}", region_id.as_ref()));
rows.push(
ScrollRowHit::new(row_id.clone(), row_idx)
.with_span_id(format!("{}:span:{row_idx}", region_id.as_ref()))
.with_item_id(row_id),
);
}
Some((viewport, start, rows))
}
#[must_use]
pub fn row_count(&self) -> usize {
self.rows.len()
}
fn row_label(&self, row_idx: usize) -> String {
self.rows[row_idx]
.iter()
.map(Cow::as_ref)
.collect::<Vec<_>>()
.join(" | ")
}
fn compute_widths(&self, total_width: u16) -> Vec<u16> {
if let Some(ref w) = self.widths {
return w.clone();
}
let cols = self.headers.len().max(1) as u16;
let per_col = total_width / cols;
let mut widths = vec![per_col; cols as usize];
let remainder = total_width.saturating_sub(per_col * cols);
for w in widths.iter_mut().take(remainder as usize) {
*w += 1;
}
widths
}
}
impl Widget for Table<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) || self.headers.is_empty() {
return;
}
let col_widths = self.compute_widths(inner.width);
let header_height = 1u16;
let divider_height = 1u16;
let data_start_y = inner.y + header_height + divider_height;
let visible_rows = inner.height.saturating_sub(header_height + divider_height);
for (col_idx, header) in self.headers.iter().enumerate() {
if col_idx >= col_widths.len() {
break;
}
let col_x = inner.x + col_widths[..col_idx].iter().sum::<u16>();
let w = col_widths[col_idx];
paint_text(
Rect::new(col_x, inner.y, w, 1),
buf,
header.as_ref(),
Style::default()
.fg(self.palette.pulse)
.add_modifier(Modifier::BOLD),
);
}
let div_y = inner.y + header_height;
for col_idx in 0..self.headers.len().min(col_widths.len()) {
let col_x = inner.x + col_widths[..col_idx].iter().sum::<u16>();
let w = col_widths[col_idx];
for dx in 0..w {
set_styled_char(
buf,
col_x + dx,
div_y,
'─',
Style::default().fg(self.palette.shadow),
);
}
}
let total = self.rows.len() as u16;
let start = self
.scroll_offset
.min(total.saturating_sub(visible_rows.min(total)));
for row_offset in 0..visible_rows {
let row_idx = start as usize + row_offset as usize;
let y = data_start_y + row_offset;
if row_idx >= self.rows.len() {
break;
}
let is_selected = self.selected == Some(row_idx);
let row = &self.rows[row_idx];
for (col_idx, cell) in row.iter().enumerate() {
if col_idx >= col_widths.len() {
break;
}
let col_x = inner.x + col_widths[..col_idx].iter().sum::<u16>();
let w = col_widths[col_idx];
let cell_chars: Vec<char> = cell.chars().collect();
for dx in 0..w {
let ch = cell_chars.get(dx as usize).copied().unwrap_or(' ');
let style = if is_selected {
Style::default()
.fg(self.palette.high)
.bg(self.palette.shadow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.palette.high)
};
set_styled_char(buf, col_x + dx, y, ch, style);
}
}
}
}
}
#[derive(Clone, Debug)]
pub struct Sparkline<'a> {
data: Vec<u16>,
max_value: Option<u16>,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for Sparkline<'_> {
fn eq(&self, other: &Self) -> bool {
self.data == other.data
&& self.max_value == other.max_value
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl<'a> Sparkline<'a> {
#[must_use]
pub fn new(data: Vec<u16>) -> Self {
Self {
data,
max_value: None,
palette: AislingPalette::phosphor(),
block: None,
}
}
#[must_use]
pub fn max_value(mut self, max: u16) -> Self {
self.max_value = Some(max);
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 Sparkline<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) || self.data.is_empty() {
return;
}
let max = self
.max_value
.unwrap_or_else(|| self.data.iter().copied().max().unwrap_or(1))
.max(1);
let bottom = inner.y.saturating_add(inner.height);
for col in 0..inner.width {
let data_idx = (col as usize * self.data.len()) / usize::from(inner.width);
let value = self.data.get(data_idx).copied().unwrap_or(0);
let bar_height =
((f64::from(value) / f64::from(max)) * f64::from(inner.height)).round() as u16;
let bar_y = bottom.saturating_sub(bar_height);
for y in bar_y..bottom {
let noise = field_noise(inner.x + col, y, 0);
let style = Style::default()
.fg(self.palette.lane(noise))
.add_modifier(Modifier::BOLD);
set_styled_char(buf, inner.x + col, y, '█', style);
}
for y in inner.y..bar_y {
set_styled_char(
buf,
inner.x + col,
y,
'·',
Style::default().fg(self.palette.shadow),
);
}
}
}
}
#[derive(Clone, Debug)]
pub struct Gauge<'a> {
ratio: f64,
label: Option<Cow<'a, str>>,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for Gauge<'_> {
fn eq(&self, other: &Self) -> bool {
self.ratio == other.ratio
&& self.label == other.label
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl<'a> Gauge<'a> {
#[must_use]
pub fn new(ratio: f64) -> Self {
Self {
ratio: ratio.clamp(0.0, 1.0),
label: None,
palette: AislingPalette::cypherpunk(),
block: None,
}
}
#[must_use]
pub fn ratio(&self) -> f64 {
self.ratio
}
#[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 Gauge<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) {
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);
if offset < filled {
set_styled_char(
buf,
x,
y,
'█',
Style::default()
.fg(self.palette.mid)
.add_modifier(Modifier::BOLD),
);
} else {
set_styled_char(buf, x, y, '░', 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)]
pub struct Paragraph<'a> {
text: Cow<'a, str>,
scroll_offset: u16,
palette: AislingPalette,
block: Option<Block<'a>>,
}
impl PartialEq for Paragraph<'_> {
fn eq(&self, other: &Self) -> bool {
self.text == other.text
&& self.scroll_offset == other.scroll_offset
&& self.palette == other.palette
&& option_block_eq(self.block.as_ref(), other.block.as_ref())
}
}
impl<'a> Paragraph<'a> {
#[must_use]
pub fn new(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
scroll_offset: 0,
palette: AislingPalette::cypherpunk(),
block: None,
}
}
#[must_use]
pub fn scroll_offset(mut self, offset: u16) -> Self {
self.scroll_offset = offset;
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
}
fn wrap_lines(&self, width: u16) -> Vec<Cow<'_, str>> {
if width == 0 {
return Vec::new();
}
let mut result = Vec::new();
for raw_line in self.text.lines() {
if raw_line.is_empty() {
result.push(Cow::Borrowed(""));
continue;
}
let mut remaining = raw_line;
while !remaining.is_empty() {
let w = usize::from(width);
if remaining.len() <= w {
result.push(Cow::Borrowed(remaining));
break;
}
let break_at = remaining[..w].rfind(' ').map(|p| p + 1).unwrap_or(w);
result.push(Cow::Borrowed(&remaining[..break_at]));
remaining = &remaining[break_at..];
}
}
result
}
}
impl Widget for Paragraph<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
let inner = self
.block
.as_ref()
.map_or(area, |block| block_content_area(block, area));
if let Some(block) = &self.block {
block.render(buf, area);
}
if is_empty(inner) {
return;
}
let wrapped = self.wrap_lines(inner.width);
let total = wrapped.len() as u16;
let start = self
.scroll_offset
.min(total.saturating_sub(inner.height.min(total)));
for row in 0..inner.height {
let line_idx = start as usize + row as usize;
let y = inner.y + row;
if line_idx >= wrapped.len() {
break;
}
let line = &wrapped[line_idx];
let chars: Vec<char> = line.chars().collect();
for col in 0..inner.width {
let ch = chars.get(col as usize).copied().unwrap_or(' ');
let style = if ch == ' ' {
Style::default()
} else {
Style::default().fg(self.palette.high)
};
set_styled_char(buf, inner.x + col, y, ch, style);
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Align {
Left,
Center,
Right,
}
#[derive(Clone, Debug)]
pub struct StatusSection<'a> {
text: Cow<'a, str>,
align: Align,
}
#[derive(Clone, Debug)]
pub struct StatusBar<'a> {
sections: Vec<StatusSection<'a>>,
palette: AislingPalette,
}
impl PartialEq for StatusBar<'_> {
fn eq(&self, other: &Self) -> bool {
self.sections.len() == other.sections.len()
&& self
.sections
.iter()
.zip(other.sections.iter())
.all(|(a, b)| a.text == b.text && a.align == b.align)
&& self.palette == other.palette
}
}
impl<'a> StatusBar<'a> {
#[must_use]
pub fn new() -> Self {
Self {
sections: Vec::new(),
palette: AislingPalette::cypherpunk(),
}
}
#[must_use]
pub fn left(mut self, text: impl Into<Cow<'a, str>>) -> Self {
self.sections.push(StatusSection {
text: text.into(),
align: Align::Left,
});
self
}
#[must_use]
pub fn center(mut self, text: impl Into<Cow<'a, str>>) -> Self {
self.sections.push(StatusSection {
text: text.into(),
align: Align::Center,
});
self
}
#[must_use]
pub fn right(mut self, text: impl Into<Cow<'a, str>>) -> Self {
self.sections.push(StatusSection {
text: text.into(),
align: Align::Right,
});
self
}
#[must_use]
pub fn palette(mut self, palette: AislingPalette) -> Self {
self.palette = palette;
self
}
}
impl Default for StatusBar<'_> {
fn default() -> Self {
Self::new()
}
}
impl Widget for StatusBar<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
if is_empty(area) || self.sections.is_empty() {
return;
}
let bg_style = Style::default()
.fg(self.palette.high)
.bg(self.palette.shadow);
for x in area.x..area.x.saturating_add(area.width) {
for y in area.y..area.y.saturating_add(area.height) {
set_styled_char(buf, x, y, ' ', bg_style);
}
}
let left_sections: Vec<_> = self
.sections
.iter()
.filter(|s| s.align == Align::Left)
.collect();
let center_sections: Vec<_> = self
.sections
.iter()
.filter(|s| s.align == Align::Center)
.collect();
let right_sections: Vec<_> = self
.sections
.iter()
.filter(|s| s.align == Align::Right)
.collect();
let mut x = area.x;
for section in &left_sections {
let text: Vec<char> = section.text.chars().collect();
let max_len = text
.len()
.min(usize::from(area.width.saturating_sub(x - area.x)));
for (i, &ch) in text.iter().take(max_len).enumerate() {
set_styled_char(
buf,
x + i as u16,
area.y,
ch,
Style::default()
.fg(self.palette.high)
.add_modifier(Modifier::BOLD),
);
}
x += max_len as u16;
}
for section in ¢er_sections {
let text: Vec<char> = section.text.chars().collect();
let available = area.width.saturating_sub(x - area.x);
let start_offset = available.saturating_sub(text.len() as u16) / 2;
x += start_offset;
for (i, &ch) in text.iter().take(usize::from(available)).enumerate() {
set_styled_char(
buf,
x + i as u16,
area.y,
ch,
Style::default()
.fg(self.palette.high)
.add_modifier(Modifier::BOLD),
);
}
x += text.len() as u16;
}
let right_x = area.x + area.width;
let mut render_x = right_x;
for section in right_sections.iter().rev() {
let text: Vec<char> = section.text.chars().collect();
render_x = render_x.saturating_sub(text.len() as u16);
for (i, &ch) in text.iter().enumerate() {
if render_x + (i as u16) >= area.x && render_x + (i as u16) < right_x {
set_styled_char(
buf,
render_x + i as u16,
area.y,
ch,
Style::default()
.fg(self.palette.high)
.add_modifier(Modifier::BOLD),
);
}
}
}
}
}
#[derive(Clone, Debug)]
pub struct Bordered<'a> {
title: Cow<'a, str>,
palette: AislingPalette,
}
impl PartialEq for Bordered<'_> {
fn eq(&self, other: &Self) -> bool {
self.title == other.title && self.palette == other.palette
}
}
impl<'a> Bordered<'a> {
#[must_use]
pub fn new(title: impl Into<Cow<'a, str>>) -> Self {
Self {
title: title.into(),
palette: AislingPalette::cypherpunk(),
}
}
#[must_use]
pub fn palette(mut self, palette: AislingPalette) -> Self {
self.palette = palette;
self
}
pub fn render_inner(&self, buf: &mut Buffer, area: Rect) -> Rect {
let block = Block::new(self.title.as_ref())
.with_borders(BorderStyle::Plain)
.with_border_color(self.palette.mid);
let inner = block_content_area(&block, area);
block.render(buf, area);
inner
}
}
impl Widget for Bordered<'_> {
fn render(&self, buf: &mut Buffer, area: Rect) {
self.render_inner(buf, area);
}
}
fn is_empty(area: Rect) -> bool {
area.width == 0 || area.height == 0
}
fn block_content_area(block: &Block<'_>, area: Rect) -> Rect {
match block.borders {
BorderStyle::None => area,
_ => Rect::new(
area.x.saturating_add(1),
area.y.saturating_add(1),
area.width.saturating_sub(2),
area.height.saturating_sub(2),
),
}
}
fn option_block_eq(left: Option<&Block<'_>>, right: Option<&Block<'_>>) -> bool {
match (left, right) {
(Some(left), Some(right)) => block_eq(left, right),
(None, None) => true,
_ => false,
}
}
fn block_eq(left: &Block<'_>, right: &Block<'_>) -> bool {
left.title == right.title
&& left.title_right == right.title_right
&& left.borders == right.borders
&& left.border_color == right.border_color
&& left.bg == right.bg
&& left.style == right.style
&& left.inner_margin == right.inner_margin
}
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 is_empty(area) {
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;
}
set_styled_char(buf, x, area.y, glyph, style);
}
}
fn clipped_text(text: &str, width: u16) -> String {
text.chars().take(usize::from(width)).collect()
}
fn set_cell_bg(buf: &mut Buffer, x: u16, y: u16, bg: Color) {
let Some(mut cell) = buf.get(usize::from(x), usize::from(y)).copied() else {
return;
};
cell.bg = Some(bg);
buf.set(usize::from(x), usize::from(y), cell);
}
fn set_cell_style(buf: &mut Buffer, x: u16, y: u16, style: Style) {
let Some(cell) = buf.get(usize::from(x), usize::from(y)).copied() else {
return;
};
buf.set(usize::from(x), usize::from(y), replace_style(cell, style));
}
fn set_styled_char(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) {
buf.set(
usize::from(x),
usize::from(y),
replace_style(
Cell::new(ch, style.fg.unwrap_or(Color::WHITE), style.bg),
style,
),
);
}
fn replace_style(mut cell: Cell, style: Style) -> Cell {
cell.fg = style.fg.unwrap_or(Color::WHITE);
cell.bg = style.bg;
cell.bold = (style.bold || style.add_modifier.contains(Modifier::BOLD))
&& !style.sub_modifier.contains(Modifier::BOLD);
cell.italic = (style.italic || style.add_modifier.contains(Modifier::ITALIC))
&& !style.sub_modifier.contains(Modifier::ITALIC);
cell.underlined = (style.underlined || style.add_modifier.contains(Modifier::UNDERLINED))
&& !style.sub_modifier.contains(Modifier::UNDERLINED);
cell
}
#[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::new(usize::from(area.width), usize::from(area.height));
AislingEffect::new(8).intensity(7).apply(area, &mut buf);
}
#[test]
fn scrin_effect_renders_without_panic() {
let area = Rect::new(0, 0, 32, 6);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
ScrinEffect::new(EffectKind::Matrix, "scrin")
.tick(4)
.duration(12)
.seed(7)
.render(&mut buf, area);
}
#[test]
fn scrin_loader_renders_without_panic() {
let area = Rect::new(0, 0, 36, 4);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
ScrinLoader::new(LoaderKind::Bar, 0.42)
.tick(3)
.label("loading")
.unit("items")
.fraction(true)
.render(&mut buf, area);
}
#[test]
fn scrin_loader_progress_is_clamped() {
assert_eq!(ScrinLoader::new(LoaderKind::Bar, 2.0).progress(), 1.0);
assert_eq!(ScrinLoader::new(LoaderKind::Bar, -1.0).progress(), 0.0);
}
#[test]
fn flicker_panel_renders_without_panic() {
let area = Rect::new(0, 0, 20, 3);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
FlickerPanel::new("test")
.tick(5)
.intensity(3)
.render(&mut buf, area);
}
#[test]
fn waveform_renders_without_panic() {
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
Waveform::new(4.0, 0.6).tick(12).render(&mut buf, area);
}
#[test]
fn waveform_short_height_is_noop() {
let area = Rect::new(0, 0, 20, 2);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
Waveform::new(4.0, 0.6).render(&mut buf, area);
}
#[test]
fn pulse_ring_renders_without_panic() {
let area = Rect::new(0, 0, 30, 15);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
PulseRing::new(3).tick(7).render(&mut buf, area);
}
#[test]
fn pulse_ring_zero_area_is_noop() {
let area = Rect::new(0, 0, 0, 0);
let mut buf = Buffer::new(1, 1);
PulseRing::new(5).render(&mut buf, area);
}
#[test]
fn radar_renders_without_panic() {
let area = Rect::new(0, 0, 20, 20);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
Radar::new(5).tick(10).render(&mut buf, area);
}
#[test]
fn radar_small_area_is_noop() {
let area = Rect::new(0, 0, 1, 1);
let mut buf = Buffer::new(1, 1);
Radar::new(5).render(&mut buf, area);
}
#[test]
fn orb_field_renders_without_panic() {
let area = Rect::new(0, 0, 30, 10);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
OrbField::new(8).tick(5).render(&mut buf, area);
}
#[test]
fn neon_border_renders_without_panic() {
let area = Rect::new(0, 0, 20, 10);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
NeonBorder::new(Block::new("test"))
.tick(12)
.render(&mut buf, area);
}
#[test]
fn stream_panel_renders_without_panic() {
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
StreamPanel::new()
.push_line("fn main() {")
.push_line(" println!(\"hello\");")
.push_line("}")
.show_line_numbers(true)
.tick(5)
.render(&mut buf, area);
}
#[test]
fn stream_panel_empty_is_noop() {
let area = Rect::new(0, 0, 10, 5);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
StreamPanel::new().render(&mut buf, area);
}
#[test]
fn stream_panel_follow_tail() {
let lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
let area = Rect::new(0, 0, 30, 5);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
let panel = StreamPanel::new()
.lines(lines)
.follow_tail(true)
.show_line_numbers(true);
panel.render(&mut buf, area);
assert_eq!(panel.line_count(), 50);
}
#[test]
fn stream_panel_exports_selectable_scroll_metadata() {
let area = Rect::new(0, 0, 24, 3);
let panel = StreamPanel::new()
.lines(["alpha", "bravo", "charlie", "delta"])
.show_line_numbers(true);
let spans = panel.selectable_spans("stream", area);
let (_, start, rows) = panel.scroll_region("stream", area).unwrap();
assert_eq!(spans.len(), 3);
assert_eq!(start, 1);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].logical_row, 1);
}
#[test]
fn split_pane_vertical() {
let area = Rect::new(0, 0, 80, 24);
let (a, b, div) = SplitPane::vertical().ratio(0.6).split(area);
assert_eq!(a.width, 48);
assert_eq!(b.width, 32);
assert_eq!(div.width, 0);
}
#[test]
fn split_pane_vertical_with_divider() {
let area = Rect::new(0, 0, 80, 24);
let (a, b, div) = SplitPane::vertical().ratio(0.5).divider('│').split(area);
assert_eq!(a.width + b.width + div.width, 80);
assert_eq!(div.width, 1);
}
#[test]
fn split_pane_horizontal() {
let area = Rect::new(0, 0, 80, 24);
let (a, b, _div) = SplitPane::horizontal().ratio(0.75).split(area);
assert_eq!(a.height, 18);
assert_eq!(b.height, 6);
}
#[test]
fn split_pane_empty_area() {
let (a, b, div) = SplitPane::vertical().split(Rect::ZERO);
assert_eq!(a, Rect::ZERO);
assert_eq!(b, Rect::ZERO);
assert_eq!(div, Rect::ZERO);
}
#[test]
fn list_renders_without_panic() {
let area = Rect::new(0, 0, 30, 8);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
List::new()
.item("Apple")
.item("Banana")
.item("Cherry")
.selected(Some(1))
.render(&mut buf, area);
}
#[test]
fn list_scrolls_to_selected() {
let items: Vec<String> = (0..30).map(|i| format!("Item {i}")).collect();
let area = Rect::new(0, 0, 20, 5);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
List::new()
.items(items)
.selected(Some(25))
.render(&mut buf, area);
}
#[test]
fn list_exports_selectable_scroll_metadata() {
let area = Rect::new(0, 0, 20, 2);
let list = List::new().items(["one", "two", "three"]).selected(Some(2));
let spans = list.selectable_spans("list", area);
let regions = list.hit_regions("list", area);
let (_, start, rows) = list.scroll_region("list", area).unwrap();
assert_eq!(spans.len(), 2);
assert_eq!(regions.len(), 3);
assert_eq!(start, 1);
assert_eq!(rows[1].logical_row, 2);
}
#[test]
fn tab_bar_renders_without_panic() {
let area = Rect::new(0, 0, 60, 3);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
TabBar::new(["Tab 1", "Tab 2", "Tab 3"])
.selected(1)
.tick(3)
.render(&mut buf, area);
}
#[test]
fn tab_bar_many_tabs() {
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
TabBar::new(["A", "B", "C", "D", "E", "F", "G", "H"]).render(&mut buf, area);
}
#[test]
fn table_renders_without_panic() {
let area = Rect::new(0, 0, 60, 10);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
Table::new(["Name", "Age", "City"])
.row(["Alice", "30", "NYC"])
.row(["Bob", "25", "LA"])
.row(["Carol", "35", "Chicago"])
.selected(Some(1))
.render(&mut buf, area);
}
#[test]
fn table_with_explicit_widths() {
let area = Rect::new(0, 0, 40, 5);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
Table::new(["A", "B"])
.row(["x", "y"])
.widths(vec![20, 20])
.render(&mut buf, area);
}
#[test]
fn table_exports_selectable_scroll_metadata() {
let area = Rect::new(0, 0, 30, 5);
let table = Table::new(["Name", "State"])
.row(["alpha", "idle"])
.row(["bravo", "run"])
.row(["charlie", "done"]);
let spans = table.selectable_spans("table", area);
let regions = table.hit_regions("table", area);
let (viewport, start, rows) = table.scroll_region("table", area).unwrap();
assert_eq!(spans.len(), 3);
assert_eq!(regions.len(), 4);
assert_eq!(viewport.y, 2);
assert_eq!(start, 0);
assert_eq!(rows[2].logical_row, 2);
}
#[test]
fn sparkline_renders_without_panic() {
let area = Rect::new(0, 0, 30, 5);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
Sparkline::new(vec![1, 3, 5, 2, 8, 4, 6, 3, 7, 9]).render(&mut buf, area);
}
#[test]
fn sparkline_with_max_value() {
let area = Rect::new(0, 0, 20, 3);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
Sparkline::new(vec![5, 10, 15])
.max_value(20)
.render(&mut buf, area);
}
#[test]
fn sparkline_empty_data_is_noop() {
let area = Rect::new(0, 0, 20, 3);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
Sparkline::new(vec![]).render(&mut buf, area);
}
#[test]
fn gauge_simple_renders_without_panic() {
let area = Rect::new(0, 0, 30, 3);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
Gauge::new(0.65).label("65%").render(&mut buf, area);
}
#[test]
fn simple_gauge_ratio_is_clamped() {
assert_eq!(Gauge::new(2.0).ratio(), 1.0);
assert_eq!(Gauge::new(-1.0).ratio(), 0.0);
}
#[test]
fn paragraph_renders_without_panic() {
let area = Rect::new(0, 0, 30, 8);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
Paragraph::new(
"Hello world. This is a longer paragraph that should wrap across multiple lines.",
)
.render(&mut buf, area);
}
#[test]
fn paragraph_scrolls() {
let text = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8";
let area = Rect::new(0, 0, 20, 3);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
Paragraph::new(text).scroll_offset(3).render(&mut buf, area);
}
#[test]
fn status_bar_renders_without_panic() {
let area = Rect::new(0, 0, 60, 1);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
StatusBar::new()
.left("Left")
.center("Center")
.right("Right")
.render(&mut buf, area);
}
#[test]
fn status_bar_only_left() {
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
StatusBar::new().left("Hello").render(&mut buf, area);
}
#[test]
fn bordered_renders_without_panic() {
let area = Rect::new(0, 0, 30, 10);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
Bordered::new("Container").render(&mut buf, area);
}
#[test]
fn bordered_returns_inner_area() {
let area = Rect::new(0, 0, 30, 10);
let mut buf = Buffer::new(usize::from(area.width), usize::from(area.height));
let inner = Bordered::new("Title").render_inner(&mut buf, area);
assert_eq!(inner.x, 1);
assert_eq!(inner.y, 1);
assert_eq!(inner.width, 28);
assert_eq!(inner.height, 8);
}
}