use alloc::format;
use rlvgl_audio_meters_core::{Ballistic, BallisticState};
use rlvgl_core::event::Event;
use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr};
use rlvgl_core::renderer::Renderer;
use rlvgl_core::widget::{Color, Rect, Widget};
use super::skin::{MeterColorId, MeterType, Skin};
const DEFAULT_TEXT: Color = Color(0xdd, 0xe2, 0xe6, 0xff);
const DEFAULT_BACKGROUND: Color = Color(0x08, 0x0a, 0x0c, 0xff);
const PEAK_DECAY_DB_PER_S: f32 = 12.0;
pub struct NumericPeak {
bounds: Rect,
skin: &'static Skin,
ballistic: BallisticState,
reading_db: f32,
peak_db: f32,
peak_age_s: f32,
font: WidgetFont,
}
impl NumericPeak {
pub fn new(bounds: Rect, skin: &'static Skin) -> Self {
assert!(
matches!(skin.meter_type, MeterType::Numeric),
"NumericPeak requires a skin with meter_type = Numeric (got {:?} for skin `{}`)",
skin.meter_type,
skin.id
);
Self {
bounds,
skin,
ballistic: BallisticState::new(skin.default_ballistic),
reading_db: rlvgl_audio_meters_core::NEG_INFINITY_FLOOR_DB,
peak_db: rlvgl_audio_meters_core::NEG_INFINITY_FLOOR_DB,
peak_age_s: 0.0,
font: WidgetFont::new(),
}
}
pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
self.font.set(font);
}
pub fn set_ballistic(&mut self, kind: Ballistic) {
self.ballistic = BallisticState::new(kind);
self.reading_db = rlvgl_audio_meters_core::NEG_INFINITY_FLOOR_DB;
self.peak_db = rlvgl_audio_meters_core::NEG_INFINITY_FLOOR_DB;
self.peak_age_s = 0.0;
}
pub fn reset(&mut self) {
self.ballistic.reset();
self.reading_db = rlvgl_audio_meters_core::NEG_INFINITY_FLOOR_DB;
self.peak_db = rlvgl_audio_meters_core::NEG_INFINITY_FLOOR_DB;
self.peak_age_s = 0.0;
}
pub fn update(&mut self, dbfs: f32, dt: f32) -> f32 {
self.reading_db = self.ballistic.update(dbfs, dt);
if self.reading_db >= self.peak_db {
self.peak_db = self.reading_db;
self.peak_age_s = 0.0;
} else {
self.peak_age_s += dt;
let hold_s = self.skin.layout.peak_hold_ms / 1000.0;
if self.peak_age_s > hold_s {
let overage = self.peak_age_s - hold_s;
self.peak_db -= PEAK_DECAY_DB_PER_S * overage;
self.peak_age_s = hold_s;
if self.peak_db < self.reading_db {
self.peak_db = self.reading_db;
self.peak_age_s = 0.0;
}
}
}
self.reading_db
}
pub fn reading_db(&self) -> f32 {
self.reading_db
}
pub fn peak_db(&self) -> f32 {
self.peak_db
}
pub fn skin(&self) -> &'static Skin {
self.skin
}
}
impl Widget for NumericPeak {
fn bounds(&self) -> Rect {
self.bounds
}
fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
Some(&mut self.font)
}
fn draw(&self, renderer: &mut dyn Renderer) {
let bg = self.skin.secondary.background.unwrap_or(DEFAULT_BACKGROUND);
let text_default = self.skin.secondary.scale_text.unwrap_or(DEFAULT_TEXT);
renderer.fill_rect(self.bounds, bg);
let scale = self.skin.scale;
let reading_su = scale.dbfs_to_scale_units(self.reading_db);
let peak_su = scale.dbfs_to_scale_units(self.peak_db);
let reading_col = if reading_su <= scale.range_min_db {
text_default
} else {
self.skin.palette.color(scale.zone_color_for(reading_su))
};
let peak_col = if peak_su <= scale.range_min_db {
text_default
} else if let Some(c) = self.skin.secondary.peak_hold {
c
} else {
self.skin.palette.color(scale.zone_color_for(peak_su))
};
let units = scale.label_units;
let reading_text = format!("{:>7.1} {}", reading_su, units);
let peak_text = format!("PK {:>6.1} {}", peak_su, units);
let pad = 6;
let top = (
self.bounds.x + pad,
self.bounds.y + self.bounds.height / 2 - 2,
);
let bot = (
self.bounds.x + pad,
self.bounds.y + self.bounds.height - pad,
);
let font = self.font.resolve();
let shaped = shape_text_ltr(font, &reading_text, top, 0);
renderer.draw_text_shaped(&shaped, (0, 0), reading_col);
let shaped = shape_text_ltr(font, &peak_text, bot, 0);
renderer.draw_text_shaped(&shaped, (0, 0), peak_col);
let _ = MeterColorId::Safe;
}
fn handle_event(&mut self, _event: &Event) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meters::presets::DIGITAL_STUDIO_NUMERIC;
extern crate alloc;
use alloc::string::String;
use alloc::vec::Vec;
use rlvgl_core::font::ShapedText;
struct Recorder {
rects: Vec<(Rect, Color)>,
texts: Vec<((i32, i32), String, Color)>,
}
impl Renderer for Recorder {
fn fill_rect(&mut self, rect: Rect, color: Color) {
self.rects.push((rect, color));
}
fn draw_text_shaped(&mut self, shaped: &ShapedText<'_>, _origin: (i32, i32), color: Color) {
let text: String = shaped.glyphs.iter().map(|glyph| glyph.ch).collect();
self.texts.push(((0, 0), text, color));
}
fn draw_text(&mut self, _pos: (i32, i32), _text: &str, _color: Color) {}
}
#[test]
#[should_panic(expected = "meter_type = Numeric")]
fn rejects_non_numeric_skin() {
let bargraph = &crate::meters::presets::BROADCAST_CLASSIC_BARGRAPH;
let _ = NumericPeak::new(
Rect {
x: 0,
y: 0,
width: 200,
height: 80,
},
bargraph,
);
}
#[test]
fn draws_one_bg_plus_two_text_lines() {
let bar = NumericPeak::new(
Rect {
x: 0,
y: 0,
width: 200,
height: 80,
},
&DIGITAL_STUDIO_NUMERIC,
);
let mut r = Recorder {
rects: Vec::new(),
texts: Vec::new(),
};
bar.draw(&mut r);
assert_eq!(r.rects.len(), 1, "expected exactly 1 background fill");
assert_eq!(r.texts.len(), 2, "expected 2 text lines (reading, peak)");
assert!(
r.texts[1].1.starts_with("PK"),
"second line should start with PK, got {:?}",
r.texts[1].1
);
}
#[test]
fn reading_text_advances_with_signal() {
let mut bar = NumericPeak::new(
Rect {
x: 0,
y: 0,
width: 200,
height: 80,
},
&DIGITAL_STUDIO_NUMERIC,
);
bar.update(-3.0, 1.0 / 60.0);
let mut r = Recorder {
rects: Vec::new(),
texts: Vec::new(),
};
bar.draw(&mut r);
assert!(
r.texts[0].1.contains("-3.0"),
"first line should contain reading -3.0, got {:?}",
r.texts[0].1
);
}
}