use std::time::{Duration, Instant};
use rich::measure::Measurement;
use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
use super::{Charset, Line, Orientation, Scale, cells, lines_to_segments, theme_style, truncate};
const ACROSS: [char; 8] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
const UP: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
const DEFAULT_HEIGHT: usize = 8;
const DEFAULT_BAR: usize = 20;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum MeterScale {
Linear,
Decibels {
floor: f64,
},
}
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct LevelMeter {
level: f64,
peak: Option<f64>,
held: Option<f64>,
min: f64,
max: f64,
scale: MeterScale,
bands: (f64, f64),
orientation: Orientation,
label: String,
readout: bool,
charset: Charset,
width: Option<usize>,
height: Option<usize>,
}
impl LevelMeter {
pub fn new(level: f64) -> Self {
LevelMeter {
level,
peak: None,
held: None,
min: 0.0,
max: 1.0,
scale: MeterScale::Linear,
bands: (0.7, 0.9),
orientation: Orientation::Horizontal,
label: String::new(),
readout: false,
charset: Charset::Auto,
width: None,
height: None,
}
}
pub fn peak(mut self, peak: f64) -> Self {
self.peak = Some(peak);
self
}
pub fn held(mut self, held: f64) -> Self {
self.held = Some(held);
self
}
pub fn range(mut self, min: f64, max: f64) -> Self {
let scale = Scale::new(min, max);
(self.min, self.max) = (scale.min(), scale.max());
self
}
pub fn scale(mut self, scale: MeterScale) -> Self {
self.scale = scale;
self
}
pub fn bands(mut self, warning: f64, critical: f64) -> Self {
let share = |v: f64| if v.is_nan() { 1.0 } else { v.clamp(0.0, 1.0) };
self.bands = (share(warning), share(critical).max(share(warning)));
self
}
pub fn orientation(mut self, orientation: Orientation) -> Self {
self.orientation = orientation;
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn readout(mut self, readout: bool) -> Self {
self.readout = readout;
self
}
pub fn charset(mut self, charset: Charset) -> Self {
self.charset = charset;
self
}
pub fn width(mut self, width: usize) -> Self {
self.width = Some(width);
self
}
pub fn height(mut self, rows: usize) -> Self {
self.height = Some(rows);
self
}
pub fn level(&self) -> f64 {
self.level
}
pub fn share(&self, value: f64) -> Option<f64> {
if value.is_nan() {
return None;
}
let share = match self.scale {
MeterScale::Linear => Scale::new(self.min, self.max).normalize(value)?,
MeterScale::Decibels { floor } => {
let floor = if floor.is_finite() && floor < 0.0 {
floor
} else {
-60.0
};
if value <= 0.0 {
0.0
} else {
(20.0 * value.log10() - floor) / -floor
}
}
};
Some(share.clamp(0.0, 1.0))
}
fn reading(&self) -> f64 {
[Some(self.level), self.peak, self.held]
.into_iter()
.flatten()
.filter(|v| !v.is_nan())
.fold(f64::NAN, f64::max)
}
fn reading_text(&self) -> String {
let value = self.reading();
match self.scale {
_ if value.is_nan() => "-".to_string(),
MeterScale::Linear => format!("{value:.2}"),
MeterScale::Decibels { .. } if value <= 0.0 => "-inf dB".to_string(),
MeterScale::Decibels { .. } => format!("{:.1} dB", 20.0 * value.log10()),
}
}
fn readout_width(&self) -> usize {
match self.scale {
MeterScale::Linear => {
let widest = |v: f64| format!("{v:.2}").len();
widest(self.min)
.max(widest(self.max))
.max(cells(&self.reading_text()))
}
MeterScale::Decibels { .. } => 8.max(cells(&self.reading_text())),
}
}
fn band(&self, console: &Console, share: f64) -> Style {
let key = if share >= self.bands.1 {
"chart.critical"
} else if share >= self.bands.0 {
"chart.warning"
} else {
"chart.ok"
};
theme_style(console, key)
}
fn marks(&self, cells: usize) -> Vec<usize> {
let mut marks: Vec<usize> = [self.peak, self.held]
.into_iter()
.flatten()
.filter_map(|v| self.share(v))
.map(|s| ((s * cells as f64).ceil() as usize).clamp(1, cells.max(1)) - 1)
.collect();
marks.dedup();
marks
}
fn across(&self, console: &Console, width: usize, ascii: bool) -> Vec<Line> {
let mut line = Line::new();
let label = truncate(&self.label, width, ascii);
let mut room = width;
if !label.is_empty() {
line.push(&label, Some(theme_style(console, "chart.label")));
line.pad(1);
room = room.saturating_sub(cells(&label) + 1);
}
let readout = if self.readout {
let w = self.readout_width();
(w < room).then(|| (w, self.reading_text()))
} else {
None
};
let bar = room.saturating_sub(readout.as_ref().map_or(0, |(w, _)| w + 1));
let eighths = self
.share(self.level)
.map_or(0, |s| (s * (bar * 8) as f64).round() as usize);
let marks = self.marks(bar);
let track = theme_style(console, "chart.track");
for cell in 0..bar {
let share = cell as f64 / bar as f64;
let filled = eighths.saturating_sub(cell * 8).min(8);
let filled = if ascii && filled < 4 { 0 } else { filled };
if filled > 0 && !(filled < 8 && marks.contains(&cell)) {
let glyph = if ascii { '#' } else { ACROSS[filled - 1] };
line.push(&glyph.to_string(), Some(self.band(console, share)));
} else if marks.contains(&cell) {
let glyph = if ascii { "|" } else { "│" };
let style = self
.band(console, share)
.combine(&Style::parse("bold").unwrap_or_default());
line.push(glyph, Some(style));
} else {
line.push(if ascii { "-" } else { "─" }, Some(track.clone()));
}
}
if let Some((w, text)) = readout {
line.pad(1 + w - cells(&text));
line.push(&text, Some(theme_style(console, "chart.value")));
}
vec![line]
}
fn up(&self, console: &Console, width: usize, height: usize, ascii: bool) -> Vec<Line> {
let eighths = self
.share(self.level)
.map_or(0, |s| (s * (height * 8) as f64).round() as usize);
let marks = self.marks(height);
(0..height)
.map(|r| {
let row = height - 1 - r;
let share = row as f64 / height as f64;
let filled = eighths.saturating_sub(row * 8).min(8);
let filled = if ascii && filled < 4 { 0 } else { filled };
let (glyph, style) = if filled > 0 && !(filled < 8 && marks.contains(&row)) {
let glyph = if ascii {
"#".into()
} else {
UP[filled - 1].to_string()
};
(glyph, Some(self.band(console, share)))
} else if marks.contains(&row) {
let glyph = if ascii { "-" } else { "─" };
(glyph.to_string(), Some(self.band(console, share)))
} else {
(" ".to_string(), None)
};
let mut line = Line::new();
line.push(&glyph.repeat(width), style);
line
})
.collect()
}
}
impl Renderable for LevelMeter {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let width = self
.width
.map_or(options.max_width, |w| w.min(options.max_width));
let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
let lines = match self.orientation {
Orientation::Horizontal => {
let line = self.across(console, width, ascii);
let rows = self.height.unwrap_or(1).max(1);
std::iter::repeat_n(line, rows).flatten().collect()
}
Orientation::Vertical => {
let height = self.height.or(options.height).unwrap_or(DEFAULT_HEIGHT);
self.up(console, width, height, ascii)
}
};
lines_to_segments(lines, width)
}
fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
let natural = match (self.width, self.orientation) {
(Some(width), _) => width,
(None, Orientation::Vertical) => 1,
(None, Orientation::Horizontal) => {
let label = cells(&self.label);
let readout = if self.readout {
self.readout_width() + 1
} else {
0
};
label + usize::from(label > 0) + DEFAULT_BAR + readout
}
};
Measurement::new(natural.min(1), natural)
.with_maximum(options.max_width)
.normalize()
}
}
impl crate::a11y::AccessibleText for LevelMeter {
fn accessible_text(&self, _width: usize) -> String {
let what = if self.label.is_empty() {
"Level meter".to_string()
} else {
format!("Level meter {}", self.label)
};
let text = |v: f64| match self.scale {
MeterScale::Linear => super::ValueFormat::Compact.format(v),
MeterScale::Decibels { .. } if v <= 0.0 => "-inf dB".to_string(),
MeterScale::Decibels { .. } => format!("{:.1} dB", 20.0 * v.log10()),
};
let Some(share) = self.share(self.level) else {
return format!("{what}: no level");
};
let mut parts = vec![text(self.level), super::describe::percent(share)];
if let Some(peak) = self.peak.filter(|p| !p.is_nan()) {
parts.push(format!("peak {}", text(peak)));
}
if let Some(held) = self.held.filter(|p| !p.is_nan()) {
parts.push(format!("held {}", text(held)));
}
let top = self.reading();
let band = match self.share(top) {
Some(s) if s >= self.bands.1 => "critical",
Some(s) if s >= self.bands.0 => "warning",
_ => "ok",
};
parts.push(format!("band {band}"));
format!("{what}: {}", parts.join(", "))
}
}
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct PeakHold {
hold: Duration,
fall: f64,
held: f64,
since: Option<Instant>,
shown: f64,
}
impl PeakHold {
pub fn new(hold: Duration) -> Self {
PeakHold {
hold,
fall: 0.0,
held: f64::NAN,
since: None,
shown: f64::NAN,
}
}
pub fn fall(mut self, per_second: f64) -> Self {
self.fall = if per_second.is_finite() {
per_second.max(0.0)
} else {
0.0
};
self
}
pub fn update(&mut self, peak: f64, now: Instant) -> f64 {
if peak.is_nan() {
return self.shown;
}
let since = match self.since {
Some(since) if peak < self.held => since,
_ => {
(self.held, self.since, self.shown) = (peak, Some(now), peak);
return peak;
}
};
let age = now.saturating_duration_since(since);
let shown = match age.checked_sub(self.hold) {
None => self.held,
Some(_) if self.fall == 0.0 => peak,
Some(over) => (self.held - self.fall * over.as_secs_f64()).max(peak),
};
if shown <= peak {
(self.held, self.since) = (peak, Some(now));
}
self.shown = shown;
shown
}
pub fn value(&self) -> f64 {
self.shown
}
pub fn reset(&mut self) {
(self.held, self.since, self.shown) = (f64::NAN, None, f64::NAN);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::a11y::AccessibleText;
fn plain(width: usize, meter: &LevelMeter) -> String {
let console = Console::builder().width(width).color_system(None).build();
console.render_export(meter)
}
#[test]
fn the_bar_fills_in_eighths() {
let meter = LevelMeter::new(0.5).charset(Charset::Blocks);
assert_eq!(plain(5, &meter), "██▌──\n");
assert_eq!(plain(5, &LevelMeter::new(0.0)), "─────\n");
assert_eq!(plain(5, &LevelMeter::new(2.0)), "█████\n");
}
#[test]
fn peaks_are_marked_past_the_level() {
let meter = LevelMeter::new(0.2).peak(0.6).held(1.0);
assert_eq!(plain(5, &meter), "█─│─│\n");
let under = LevelMeter::new(0.5).peak(0.5);
assert_eq!(plain(4, &under), "██──\n");
assert_eq!(plain(5, &under), "██│──\n");
}
#[test]
fn decibels_put_minus_six_near_the_middle_of_twelve() {
let half = 10f64.powf(-6.0 / 20.0);
let meter = LevelMeter::new(half)
.scale(MeterScale::Decibels { floor: -12.0 })
.readout(true)
.charset(Charset::Ascii);
assert_eq!(plain(13, &meter), "##-- -6.0 dB\n");
let silent = LevelMeter::new(0.0)
.scale(MeterScale::Decibels { floor: -60.0 })
.readout(true);
assert_eq!(plain(12, &silent), "─── -inf dB\n");
}
#[test]
fn the_bands_colour_the_bar() {
let console = Console::builder()
.width(10)
.force_terminal(true)
.color_system(Some(rich::ColorSystem::Standard))
.build();
let out = console.render_export(&LevelMeter::new(1.0));
assert!(out.contains("\u{1b}[32m███████"), "{out:?}");
assert!(out.contains("\u{1b}[33m██"), "{out:?}");
assert!(out.contains("\u{1b}[1;31m█"), "{out:?}");
}
#[test]
fn up_it_fills_from_the_bottom() {
let meter = LevelMeter::new(0.5)
.peak(0.9)
.orientation(Orientation::Vertical)
.height(4);
assert_eq!(plain(2, &meter), "──\n \n██\n██\n");
}
#[test]
fn it_measures_its_parts() {
let console = Console::builder().width(80).build();
let meter = LevelMeter::new(0.1).label("L").readout(true);
let m = meter.measure(&console, &console.options());
assert_eq!(m.maximum, 2 + 20 + 5);
}
#[test]
fn accessible_text_reads_the_level() {
let meter = LevelMeter::new(0.5).peak(0.95).label("L");
assert_eq!(
meter.accessible_text(80),
"Level meter L: 0.5, 50 percent, peak 0.95, band critical"
);
assert_eq!(
LevelMeter::new(f64::NAN).accessible_text(80),
"Level meter: no level"
);
}
#[test]
fn a_held_peak_falls_at_its_rate() {
let start = Instant::now();
let at = |ms| start + Duration::from_millis(ms);
let mut hold = PeakHold::new(Duration::from_millis(100)).fall(1.0);
hold.update(1.0, at(0));
assert!((hold.update(0.0, at(600)) - 0.5).abs() < 1e-9);
assert_eq!(hold.update(0.9, at(700)), 0.9);
assert_eq!(hold.update(0.95, at(710)), 0.95);
assert!(hold.update(f64::NAN, at(720)) == 0.95);
hold.reset();
assert!(hold.value().is_nan());
}
}