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::{MeterType, Orientation, Skin};
const DEFAULT_LED_OFF: Color = Color(0x14, 0x14, 0x18, 0xff);
const DEFAULT_BACKGROUND: Color = Color(0x08, 0x08, 0x0a, 0xff);
const DEFAULT_PEAK_HOLD: Color = Color(0xff, 0xff, 0xff, 0xff);
const PEAK_DECAY_DB_PER_S: f32 = 12.0;
pub const BARGRAPH_TICK_STRIP_PX: i32 = 36;
pub struct LedBargraph {
bounds: Rect,
skin: &'static Skin,
ballistic: BallisticState,
reading_db: f32,
peak_db: f32,
peak_age_s: f32,
pub show_ticks: bool,
font: WidgetFont,
}
impl LedBargraph {
pub fn new(bounds: Rect, skin: &'static Skin) -> Self {
assert!(
matches!(skin.meter_type, MeterType::Bargraph),
"LedBargraph requires a skin with meter_type = Bargraph (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,
show_ticks: false,
font: WidgetFont::new(),
}
}
pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
self.font.set(font);
}
pub fn with_ticks(mut self) -> Self {
self.show_ticks = true;
self
}
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) {
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;
}
}
}
}
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 LedBargraph {
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 off = self.skin.secondary.led_off.unwrap_or(DEFAULT_LED_OFF);
let peak_color = self.skin.secondary.peak_hold.unwrap_or(DEFAULT_PEAK_HOLD);
let n = self.skin.layout.led_count.max(1) as i32;
renderer.fill_rect(self.bounds, bg);
let horizontal = matches!(self.skin.layout.orientation, Orientation::Horizontal);
let led_bounds = if self.show_ticks && !horizontal {
Rect {
x: self.bounds.x,
y: self.bounds.y,
width: (self.bounds.width - BARGRAPH_TICK_STRIP_PX).max(1),
height: self.bounds.height,
}
} else {
self.bounds
};
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 lo = scale.range_min_db;
let hi = scale.range_max_db;
let span = (hi - lo).max(f32::EPSILON);
let lit_frac = ((reading_su - lo) / span).clamp(0.0, 1.0);
let lit_segments = (lit_frac * n as f32 + 0.5) as i32;
let peak_frac = ((peak_su - lo) / span).clamp(0.0, 1.0);
let peak_segment = ((peak_frac * n as f32 + 0.5) as i32 - 1).clamp(0, n - 1);
for i in 0..n {
let cell = segment_rect(led_bounds, n, i, horizontal);
let centre_frac = (i as f32 + 0.5) / n as f32;
let centre_su = lo + centre_frac * span;
let zone_col = self.skin.palette.color(scale.zone_color_for(centre_su));
let lit = i < lit_segments;
let cell_fill = if lit { zone_col } else { off };
renderer.fill_rect(cell, cell_fill);
if i == peak_segment
&& self.skin.layout.peak_hold_ms > 0.0
&& peak_su > lo
&& lit_segments < n
{
renderer.fill_rect(cell, peak_color);
}
}
if self.show_ticks && !horizontal {
self.draw_ticks(renderer, led_bounds, lo, span);
}
}
fn handle_event(&mut self, _event: &Event) -> bool {
false
}
}
const DEFAULT_MAJOR_TICK: Color = Color(0xa0, 0xa0, 0xa8, 0xff);
const DEFAULT_SCALE_TEXT: Color = Color(0xcf, 0xcf, 0xd2, 0xff);
impl LedBargraph {
fn draw_ticks(&self, renderer: &mut dyn Renderer, led_bounds: Rect, lo: f32, span: f32) {
use alloc::format;
let scale = self.skin.scale;
let major_col = self.skin.secondary.major_tick.unwrap_or(DEFAULT_MAJOR_TICK);
let text_col = self.skin.secondary.scale_text.unwrap_or(DEFAULT_SCALE_TEXT);
let strip_x = led_bounds.x + led_bounds.width;
let tick_w = 8;
let tick_h = 2;
for &m in scale.majors {
let frac = ((m - lo) / span).clamp(0.0, 1.0);
let y =
led_bounds.y + led_bounds.height - 1 - (frac * (led_bounds.height as f32)) as i32;
renderer.fill_rect(
Rect {
x: strip_x,
y: y - tick_h / 2,
width: tick_w,
height: tick_h,
},
major_col,
);
let lbl_x = strip_x + tick_w + 2;
let lbl_y = y + 4;
let font = self.font.resolve();
match scale.label_for_major(m) {
Some(s) => {
let shaped = shape_text_ltr(font, s, (lbl_x, lbl_y), 0);
renderer.draw_text_shaped(&shaped, (0, 0), text_col);
}
None => {
let formatted = format!("{m:.0}");
let shaped = shape_text_ltr(font, &formatted, (lbl_x, lbl_y), 0);
renderer.draw_text_shaped(&shaped, (0, 0), text_col);
}
}
}
}
}
fn segment_rect(outer: Rect, n: i32, i: i32, horizontal: bool) -> Rect {
if horizontal {
let cell_w = outer.width / n;
let x = outer.x + i * cell_w;
let w = if i == n - 1 {
outer.width - i * cell_w
} else {
cell_w
};
Rect {
x,
y: outer.y,
width: w.max(1),
height: outer.height,
}
} else {
let cell_h = outer.height / n;
let y_from_top = outer.y + outer.height - (i + 1) * cell_h;
let h = if i == n - 1 {
outer.height - (n - 1) * cell_h
} else {
cell_h
};
Rect {
x: outer.x,
y: y_from_top.max(outer.y),
width: outer.width,
height: h.max(1),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meters::presets::BROADCAST_CLASSIC_BARGRAPH;
use rlvgl_audio_meters_core::Ballistic;
use rlvgl_core::font::ShapedText;
use rlvgl_core::renderer::Renderer;
struct RecordingRenderer {
ops: alloc::vec::Vec<(Rect, Color)>,
}
impl Renderer for RecordingRenderer {
fn fill_rect(&mut self, rect: Rect, color: Color) {
self.ops.push((rect, color));
}
fn draw_text(&mut self, _position: (i32, i32), _text: &str, _color: Color) {}
fn draw_text_shaped(
&mut self,
_shaped: &ShapedText<'_>,
_origin: (i32, i32),
_color: Color,
) {
}
}
#[test]
fn draws_one_background_plus_n_segments_silent() {
let skin = &BROADCAST_CLASSIC_BARGRAPH;
let n = skin.layout.led_count as i32;
let bounds = Rect {
x: 0,
y: 0,
width: 32,
height: 256,
};
let bar = LedBargraph::new(bounds, skin);
let mut r = RecordingRenderer {
ops: alloc::vec::Vec::new(),
};
bar.draw(&mut r);
assert_eq!(
r.ops.len() as i32,
1 + n,
"expected 1 + {} ops, got {}",
n,
r.ops.len()
);
assert_eq!(r.ops[0].0, bounds);
}
#[test]
fn lit_count_increases_with_signal() {
let skin = &BROADCAST_CLASSIC_BARGRAPH;
let bounds = Rect {
x: 0,
y: 0,
width: 32,
height: 320,
};
let mut bar = LedBargraph::new(bounds, skin);
for _ in 0..120 {
bar.update(-30.0, 1.0 / 60.0);
}
let n = skin.layout.led_count as i32;
let scale_value = skin.scale.dbfs_to_scale_units(bar.reading_db());
let frac = ((scale_value - skin.scale.range_min_db)
/ (skin.scale.range_max_db - skin.scale.range_min_db))
.clamp(0.0, 1.0);
let lit_segments = (frac * n as f32 + 0.5) as i32;
assert!(
lit_segments < n,
"should not be fully lit on -30 dBFS sustained, got {}/{}",
lit_segments,
n,
);
let pivot_frac = (skin.scale.pivot_value - skin.scale.range_min_db)
/ (skin.scale.range_max_db - skin.scale.range_min_db);
let pivot_segments = (pivot_frac * n as f32 + 0.5) as i32;
assert!(
lit_segments < pivot_segments,
"expected lit ({}) < pivot ({}) for -10 dBVU sustained input",
lit_segments,
pivot_segments,
);
}
#[test]
fn show_ticks_emits_label_per_major() {
let skin = &BROADCAST_CLASSIC_BARGRAPH;
let bar = LedBargraph::new(
Rect {
x: 0,
y: 0,
width: 64,
height: 320,
},
skin,
)
.with_ticks();
struct Counter {
text_count: usize,
label_set: alloc::vec::Vec<alloc::string::String>,
}
impl Renderer for Counter {
fn fill_rect(&mut self, _r: Rect, _c: Color) {}
fn draw_text_shaped(
&mut self,
shaped: &ShapedText<'_>,
_origin: (i32, i32),
_color: Color,
) {
self.text_count += 1;
self.label_set
.push(shaped.glyphs.iter().map(|glyph| glyph.ch).collect());
}
fn draw_text(&mut self, _p: (i32, i32), _text: &str, _c: Color) {}
}
let mut c = Counter {
text_count: 0,
label_set: alloc::vec::Vec::new(),
};
bar.draw(&mut c);
let majors = skin.scale.majors.len();
assert_eq!(c.text_count, majors, "expected one label per major");
assert!(
c.label_set.iter().any(|s| s == "0"),
"expected canonical '0' label, got {:?}",
c.label_set
);
}
#[test]
fn no_ticks_means_no_text_calls() {
let skin = &BROADCAST_CLASSIC_BARGRAPH;
let bar = LedBargraph::new(
Rect {
x: 0,
y: 0,
width: 32,
height: 256,
},
skin,
);
struct Counter {
text_count: usize,
}
impl Renderer for Counter {
fn fill_rect(&mut self, _r: Rect, _c: Color) {}
fn draw_text_shaped(
&mut self,
_shaped: &ShapedText<'_>,
_origin: (i32, i32),
_color: Color,
) {
self.text_count += 1;
}
fn draw_text(&mut self, _p: (i32, i32), _t: &str, _c: Color) {}
}
let mut c = Counter { text_count: 0 };
bar.draw(&mut c);
assert_eq!(c.text_count, 0, "ticks default off; no text expected");
}
#[test]
fn ballistic_swap_resets_reading() {
let skin = &BROADCAST_CLASSIC_BARGRAPH;
let bounds = Rect {
x: 0,
y: 0,
width: 16,
height: 128,
};
let mut bar = LedBargraph::new(bounds, skin);
bar.update(-1.0, 1.0 / 60.0);
let before = bar.reading_db();
bar.set_ballistic(Ballistic::DigitalPeak);
assert!(bar.reading_db() < before);
}
}