use ratatui_core::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
widgets::Widget,
};
use crate::animation::{AnimationMode, cell_glyph, cell_intensity, interpolate_color, is_uniform};
use crate::defaults;
const BAR_FULL: &str = "\u{28FF}"; const BAR_ROUND_LEFT: &str = "\u{28BE}"; const BAR_ROUND_RIGHT: &str = "\u{2877}";
const DEFAULT_FILLS: [f32; 5] = [0.62, 0.85, 0.38, 0.74, 0.50];
#[must_use]
#[derive(Debug, Clone)]
pub struct SkeletonBrailleBar<'a> {
elapsed_ms: u64,
mode: AnimationMode,
base: Color,
highlight: Color,
empty: Color,
bars: u16,
fills: &'a [f32],
peak: Option<f32>,
peak_color: Option<Color>,
block: Option<ratatui_widgets::block::Block<'a>>,
}
impl<'a> SkeletonBrailleBar<'a> {
pub fn new(elapsed_ms: u64) -> Self {
Self {
elapsed_ms,
mode: AnimationMode::default(),
base: defaults::BASE,
highlight: defaults::HIGHLIGHT,
empty: Color::Rgb(60, 60, 60),
bars: 3,
fills: &DEFAULT_FILLS,
peak: None,
peak_color: None,
block: None,
}
}
pub fn mode(mut self, mode: AnimationMode) -> Self {
self.mode = mode;
self
}
pub fn base(mut self, color: impl Into<Color>) -> Self {
self.base = color.into();
self
}
pub fn highlight(mut self, color: impl Into<Color>) -> Self {
self.highlight = color.into();
self
}
pub fn empty(mut self, color: impl Into<Color>) -> Self {
self.empty = color.into();
self
}
pub fn bars(mut self, bars: u16) -> Self {
self.bars = bars;
self
}
pub fn fills(mut self, fills: &'a [f32]) -> Self {
self.fills = fills;
self
}
pub fn peak(mut self, peak: f32) -> Self {
self.peak = Some(peak);
self
}
pub fn peak_color(mut self, color: impl Into<Color>) -> Self {
self.peak_color = Some(color.into());
self
}
pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
self.block = Some(block);
self
}
}
impl Widget for SkeletonBrailleBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let inner = if let Some(ref block) = self.block {
let inner_area = block.inner(area);
block.render(area, buf);
inner_area
} else {
area
};
if inner.is_empty() || self.fills.is_empty() {
return;
}
let width = inner.width as usize;
let stride: u16 = 2; let bar_count = self.bars.min((inner.height + 1) / stride);
let uniform_t = is_uniform(self.mode)
.then(|| cell_intensity(self.mode, self.elapsed_ms, 0, inner.width));
let noise = self.mode == AnimationMode::Noise;
for i in 0..bar_count {
let y = inner.y + i * stride;
let row = y - inner.y;
if y >= inner.bottom() {
break;
}
let frac = self.fills[i as usize % self.fills.len()].clamp(0.0, 1.0);
let filled = ((frac * width as f32) as usize).min(width);
let peak_pos = self.peak.map(|p| {
let pos = (p.clamp(0.0, 1.0) * width as f32) as usize;
pos.max(filled.saturating_sub(1))
.min(width.saturating_sub(1))
});
for col_idx in 0..width {
let col = col_idx as u16;
let x = inner.x + col;
if noise {
let ch = cell_glyph(false, self.mode, self.elapsed_ms, row, col);
let t = uniform_t.unwrap_or(0.0);
let fg = interpolate_color(self.base, self.highlight, self.mode, t);
buf[(x, y)].set_char(ch).set_style(Style::default().fg(fg));
continue;
}
let glyph = match col_idx {
0 => BAR_ROUND_LEFT,
n if n == width - 1 => BAR_ROUND_RIGHT,
_ => BAR_FULL,
};
let fg = if peak_pos == Some(col_idx) {
self.peak_color.unwrap_or(self.highlight)
} else if col_idx < filled {
let t = uniform_t.unwrap_or_else(|| {
cell_intensity(self.mode, self.elapsed_ms, col, inner.width)
});
interpolate_color(self.base, self.highlight, self.mode, t)
} else {
self.empty
};
buf[(x, y)]
.set_symbol(glyph)
.set_style(Style::default().fg(fg));
}
}
}
}
#[cfg(feature = "pantry")]
#[path = "braille_bar.ingredient.rs"]
pub mod ingredient;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_braille_characters() {
let area = Rect::new(0, 0, 20, 5);
let mut buf = Buffer::empty(area);
SkeletonBrailleBar::new(1000)
.bars(1)
.fills(&[1.0])
.render(area, &mut buf);
let has_braille = (0..20).any(|x| {
let sym = buf[(x as u16, 0u16)].symbol();
sym.chars()
.next()
.is_some_and(|c| (0x2800..=0x28FF).contains(&(c as u32)))
});
assert!(has_braille, "expected braille characters in output");
}
#[test]
fn rounded_caps() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
SkeletonBrailleBar::new(1000)
.bars(1)
.fills(&[1.0])
.render(area, &mut buf);
assert_eq!(buf[(0, 0)].symbol(), "⢾");
assert_eq!(buf[(9, 0)].symbol(), "⡷");
}
#[test]
fn filled_cells_use_animation_color() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
SkeletonBrailleBar::new(0)
.bars(1)
.fills(&[0.5])
.base(Color::Rgb(10, 20, 30))
.empty(Color::Rgb(60, 60, 60))
.render(area, &mut buf);
assert_eq!(buf[(1, 0)].style().fg, Some(Color::Rgb(10, 20, 30)));
assert_eq!(buf[(9, 0)].style().fg, Some(Color::Rgb(60, 60, 60)));
}
#[test]
fn bars_have_gaps() {
let area = Rect::new(0, 0, 10, 5);
let mut buf = Buffer::empty(area);
SkeletonBrailleBar::new(1000)
.bars(2)
.fills(&[1.0, 1.0])
.render(area, &mut buf);
assert_ne!(buf[(0, 0)].symbol(), " ");
assert_eq!(buf[(0, 1)].symbol(), " ");
assert_ne!(buf[(0, 2)].symbol(), " ");
}
#[test]
fn peak_marker_position() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
SkeletonBrailleBar::new(1000)
.bars(1)
.fills(&[0.3])
.peak(0.7)
.peak_color(Color::Rgb(251, 146, 60))
.render(area, &mut buf);
assert_eq!(buf[(7, 0)].style().fg, Some(Color::Rgb(251, 146, 60)));
}
#[test]
fn noise_mode_fills_random_braille() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
SkeletonBrailleBar::new(1000)
.mode(AnimationMode::Noise)
.bars(1)
.fills(&[1.0])
.render(area, &mut buf);
for x in 0..10u16 {
let ch = buf[(x, 0)].symbol().chars().next().unwrap();
assert!((0x2800..=0x28FF).contains(&(ch as u32)));
}
assert_ne!(buf[(0, 0)].symbol(), "⢾");
}
#[test]
fn empty_area_is_noop() {
let area = Rect::new(0, 0, 0, 0);
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
let expected = buf.clone();
SkeletonBrailleBar::new(0).render(area, &mut buf);
assert_eq!(buf, expected);
}
}