use ratatui::{
Frame,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::Paragraph,
};
use crate::options::OptionSchema;
use crate::payload::{Body, RatioData};
use crate::theme::{self, ColorKey, Theme};
use super::{Registry, RenderOptions, Renderer, Shape};
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
pub segments: Option<u16>,
}
const COLOR_KEYS: &[ColorKey] = &[
theme::TEXT,
theme::TEXT_DIM,
theme::STATUS_OK,
theme::STATUS_WARN,
theme::STATUS_ERROR,
];
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "label",
type_hint: "string",
required: false,
default: Some("payload.label"),
description: "Optional prefix shown before the LED bar, e.g. `\"VOL\"` → `VOL ▰▰▰▱▱ 60%`. Falls back to `RatioData.label`; omitted when neither is set.",
},
OptionSchema {
name: "segments",
type_hint: "u16",
required: false,
default: Some("5"),
description: "Number of LED segments. Defaults to the canonical 5-LED silhouette; raise for finer granularity (e.g. 10) when there's room. Values below 1 are clamped to 1.",
},
OptionSchema {
name: "tone",
type_hint: "\"neutral\" | \"fill\" | \"drain\"",
required: false,
default: Some("\"neutral\""),
description: "How segment colour follows the value. `neutral` is single `theme.text`. `fill` treats the value as how-full (low → status_error, high → status_ok) — right for VU / quota progress. `drain` inverts (high → status_error) — right for usage-style readouts.",
},
OptionSchema {
name: "value_format",
type_hint: "\"percent\" | \"fraction\" | \"both\"",
required: false,
default: Some("\"percent\""),
description: "Suffix format. `fraction` and `both` require `RatioData.denominator` — otherwise they fall back to `percent`.",
},
];
const DEFAULT_SEGMENTS: u16 = 5;
const FILLED: &str = "▰";
const EMPTY: &str = "▱";
pub struct GaugeSegmentRenderer;
impl Renderer for GaugeSegmentRenderer {
fn name(&self) -> &str {
"gauge_segment"
}
fn description(&self) -> &'static str {
"Discrete LED-style bar for `Ratio`: by default five chunky pip blocks that light up in steps rather than a continuous fill. Pick this over `gauge_line` when you want a retro hardware look or coarser readability at a glance."
}
fn accepts(&self) -> &[Shape] {
&[Shape::Ratio]
}
fn color_keys(&self) -> &[ColorKey] {
COLOR_KEYS
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
body: &Body,
opts: &RenderOptions,
theme: &Theme,
_registry: &Registry,
) {
if let Body::Ratio(d) = body {
render_segment(frame, area, d, opts, theme);
}
}
}
fn render_segment(
frame: &mut Frame,
area: Rect,
data: &RatioData,
opts: &RenderOptions,
theme: &Theme,
) {
if area.width == 0 || area.height == 0 {
return;
}
let specific: Options = opts.parse_specific();
let ratio = data.value.clamp(0.0, 1.0);
let prefix = resolve_prefix(opts, data);
let suffix = format_value(ratio, data.denominator, opts.value_format.as_deref());
let segments = resolve_segment_count(specific.segments);
let filled = (f64::from(segments) * ratio).round() as u16;
let fill = tone_color(ratio, opts.tone.as_deref(), theme);
let row = Rect::new(area.x, area.y + area.height / 2, area.width, 1);
let line = Line::from(compose_spans(
&prefix, &suffix, filled, segments, fill, theme,
));
frame.render_widget(Paragraph::new(line), row);
}
fn compose_spans<'a>(
prefix: &str,
suffix: &str,
filled: u16,
segments: u16,
fill: ratatui::style::Color,
theme: &Theme,
) -> Vec<Span<'a>> {
let mut spans = Vec::with_capacity(5);
if !prefix.is_empty() {
spans.push(Span::styled(
format!("{prefix} "),
Style::default().fg(theme.text),
));
}
spans.push(Span::styled(
FILLED.repeat(filled as usize),
Style::default().fg(fill),
));
spans.push(Span::styled(
EMPTY.repeat(segments.saturating_sub(filled) as usize),
Style::default().fg(theme.text_dim),
));
spans.push(Span::styled(
format!(" {suffix}"),
Style::default().fg(theme.text),
));
spans
}
fn resolve_prefix(opts: &RenderOptions, data: &RatioData) -> String {
opts.label
.clone()
.or_else(|| data.label.clone())
.unwrap_or_default()
}
fn resolve_segment_count(opt: Option<u16>) -> u16 {
opt.unwrap_or(DEFAULT_SEGMENTS).max(1)
}
fn format_value(ratio: f64, denominator: Option<u64>, mode: Option<&str>) -> String {
let pct = (ratio * 100.0).round() as u64;
match (mode.unwrap_or("percent"), denominator) {
("fraction", Some(d)) => format!("{} of {d}", numerator(ratio, d)),
("both", Some(d)) => format!("{pct}% ({} of {d})", numerator(ratio, d)),
_ => format!("{pct}%"),
}
}
fn numerator(ratio: f64, denominator: u64) -> u64 {
(ratio * denominator as f64).round() as u64
}
fn tone_color(ratio: f64, tone: Option<&str>, theme: &Theme) -> ratatui::style::Color {
match tone.unwrap_or("neutral") {
"fill" => level_color(ratio, theme),
"drain" => level_color(1.0 - ratio, theme),
_ => theme.text,
}
}
fn level_color(ratio: f64, theme: &Theme) -> ratatui::style::Color {
if ratio < 0.20 {
theme.status_error
} else if ratio < 0.50 {
theme.status_warn
} else {
theme.status_ok
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::payload::{Payload, RatioData};
use crate::render::test_utils::{line_text, render_to_buffer_with_spec};
use crate::render::{Registry, RenderSpec};
fn payload(value: f64, label: Option<&str>) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body: Body::Ratio(RatioData {
value,
label: label.map(String::from),
denominator: None,
}),
}
}
fn registry_and_spec() -> (Registry, RenderSpec) {
(
Registry::with_builtins(),
RenderSpec::Short("gauge_segment".into()),
)
}
#[test]
fn renders_default_five_segments() {
let (registry, spec) = registry_and_spec();
let buf = render_to_buffer_with_spec(&payload(0.6, None), Some(&spec), ®istry, 20, 1);
let row = line_text(&buf, 0);
let filled = row.matches(FILLED).count();
let empty = row.matches(EMPTY).count();
assert_eq!(filled + empty, DEFAULT_SEGMENTS as usize, "row: {row:?}");
assert_eq!(filled, 3, "60% of 5 should round to 3 filled: {row:?}");
assert!(row.contains("60%"), "missing percent suffix: {row:?}");
}
#[test]
fn segments_option_overrides_count() {
let registry = Registry::with_builtins();
#[derive(serde::Deserialize)]
struct W {
render: RenderSpec,
}
let w: W = toml::from_str(r#"render = { type = "gauge_segment", segments = 10 }"#).unwrap();
let buf =
render_to_buffer_with_spec(&payload(0.3, None), Some(&w.render), ®istry, 30, 1);
let row = line_text(&buf, 0);
assert_eq!(row.matches(FILLED).count() + row.matches(EMPTY).count(), 10);
}
#[test]
fn label_prefix_renders_when_provided() {
let registry = Registry::with_builtins();
#[derive(serde::Deserialize)]
struct W {
render: RenderSpec,
}
let w: W = toml::from_str(r#"render = { type = "gauge_segment", label = "VOL" }"#).unwrap();
let buf =
render_to_buffer_with_spec(&payload(0.4, None), Some(&w.render), ®istry, 30, 1);
let row = line_text(&buf, 0);
assert!(row.starts_with("VOL"), "missing prefix: {row:?}");
}
#[test]
fn payload_label_used_when_option_missing() {
let (registry, spec) = registry_and_spec();
let buf =
render_to_buffer_with_spec(&payload(0.5, Some("CPU")), Some(&spec), ®istry, 30, 1);
let row = line_text(&buf, 0);
assert!(row.starts_with("CPU"), "missing payload label: {row:?}");
}
#[test]
fn clamps_out_of_range() {
let (registry, spec) = registry_and_spec();
let _ = render_to_buffer_with_spec(&payload(1.7, None), Some(&spec), ®istry, 30, 1);
let _ = render_to_buffer_with_spec(&payload(-0.2, None), Some(&spec), ®istry, 30, 1);
}
#[test]
fn segments_clamped_to_at_least_one() {
assert_eq!(resolve_segment_count(Some(0)), 1);
assert_eq!(resolve_segment_count(None), DEFAULT_SEGMENTS);
assert_eq!(resolve_segment_count(Some(8)), 8);
}
#[test]
fn tone_neutral_is_default_and_uses_text_colour() {
let theme = Theme::default();
assert_eq!(tone_color(0.05, None, &theme), theme.text);
assert_eq!(tone_color(0.95, Some("neutral"), &theme), theme.text);
}
#[test]
fn tone_fill_maps_low_to_error() {
let theme = Theme::default();
assert_eq!(tone_color(0.05, Some("fill"), &theme), theme.status_error);
assert_eq!(tone_color(0.30, Some("fill"), &theme), theme.status_warn);
assert_eq!(tone_color(0.80, Some("fill"), &theme), theme.status_ok);
}
#[test]
fn tone_drain_inverts_for_usage_metrics() {
let theme = Theme::default();
assert_eq!(tone_color(0.95, Some("drain"), &theme), theme.status_error);
assert_eq!(tone_color(0.70, Some("drain"), &theme), theme.status_warn);
assert_eq!(tone_color(0.20, Some("drain"), &theme), theme.status_ok);
}
#[test]
fn value_format_fraction_falls_back_without_denominator() {
assert_eq!(format_value(0.5, None, Some("fraction")), "50%");
assert_eq!(format_value(0.5, Some(10), Some("fraction")), "5 of 10");
}
#[test]
fn empty_area_does_not_panic() {
let (registry, spec) = registry_and_spec();
let _ = render_to_buffer_with_spec(&payload(0.5, None), Some(&spec), ®istry, 0, 0);
}
}