use crate::render::controls::ParamKind;
use crate::render::motion::{breath, lerp_rgb};
use crate::render::palette::RgbColor;
use crate::render::panel::{footer_hints, RenderedOverlay, RichCell};
use crate::render::theme::PanelStyle;
use crate::render::widgets::{gauge, swatch, value_color, ParamState, RowBuf};
use crate::terminal::state::NotificationLevel;
const STRIP_H: usize = 6;
const STRIP_W: usize = 80;
#[derive(Clone, Debug)]
pub struct BaseStatus {
pub preset_name: String,
pub palette_name: String,
pub time_scale_text: String,
pub population: Option<usize>,
pub dither_label: Option<String>,
pub can_undo: bool,
pub can_redo: bool,
pub accent: RgbColor,
pub is_paused: bool,
}
impl Default for BaseStatus {
fn default() -> Self {
Self {
preset_name: String::new(),
palette_name: String::new(),
time_scale_text: String::new(),
population: None,
dither_label: None,
can_undo: false,
can_redo: false,
accent: RgbColor { r: 0, g: 0, b: 0 },
is_paused: false,
}
}
}
#[derive(Clone, Debug)]
pub struct TuneView {
pub label: String,
pub value_text: String,
pub value: f32,
pub range: (f32, f32),
pub default: f32,
pub state: ParamState,
pub show_gauge: bool,
pub kind: ParamKind,
}
#[derive(Clone, Debug)]
pub enum AmbientState {
Base,
Tune {
param: TuneView,
until: f32,
},
Msg {
level: NotificationLevel,
text: String,
sticky: bool,
until: f32,
},
}
pub fn ambient_state_is_live(state: &AmbientState, now: f32) -> bool {
match state {
AmbientState::Base => true,
AmbientState::Tune { until, .. } => now <= *until,
AmbientState::Msg { sticky, until, .. } => *sticky || now <= *until,
}
}
fn priority(state: &AmbientState) -> u8 {
match state {
AmbientState::Base => 0,
AmbientState::Tune { .. } => 1,
AmbientState::Msg {
level: NotificationLevel::Error,
..
} => 3,
AmbientState::Msg { .. } => 2,
}
}
pub fn resolve(states: &[AmbientState], now: f32) -> &AmbientState {
states
.iter()
.filter(|s| ambient_state_is_live(s, now))
.max_by_key(|s| priority(s))
.expect("resolve: states slice must contain at least one entry (Base sentinel)")
}
pub fn bump_tune(active: &mut AmbientState, now: f32, hold: f32) {
if let AmbientState::Tune { until, .. } = active {
*until = now + hold;
}
}
pub fn surface_tune(states: &mut Vec<AmbientState>, param: TuneView, now: f32, hold: f32) {
states.retain(|s| {
!matches!(
s,
AmbientState::Msg {
level: NotificationLevel::Info,
..
}
)
});
match states
.iter_mut()
.find(|s| matches!(s, AmbientState::Tune { .. }))
{
Some(slot) => {
*slot = AmbientState::Tune { param, until: now };
bump_tune(slot, now, hold);
}
None => {
states.push(AmbientState::Tune {
param,
until: now + hold,
});
}
}
}
const MODAL_INNER_W: usize = 56;
fn base_content_row(w: usize, st: &PanelStyle, base: &BaseStatus) -> RowBuf {
let mut row = RowBuf::new_matte(w, st.status_bar_bg);
let mut col = 2usize;
row.put(col, &base.preset_name, Some(st.text_primary), None);
col += base.preset_name.chars().count();
row.put(col, " ◦ ", Some(st.muted), None);
col += 5;
row.put(col, &base.time_scale_text, Some(st.text_primary), None);
col += base.time_scale_text.chars().count();
if w >= 52 {
row.put(col, " ◦ ", Some(st.muted), None);
col += 5;
let sw = swatch(base.accent);
let sw_w = sw.len();
row.put_cells(col, &sw, None);
col += sw_w;
row.put(col, " ", None, None);
col += 2;
row.put(col, &base.palette_name, Some(st.text_primary), None);
col += base.palette_name.chars().count() + 2;
}
if let Some(pop) = base.population {
if w >= 68 {
let pop_str = format!("◦ {}k ", pop / 1000);
row.put(col, &pop_str, Some(st.muted), None);
col += pop_str.chars().count();
}
}
if let Some(ref dither) = base.dither_label {
if w >= 60 {
let d_str = format!("◦ {} ", dither);
row.put(col, &d_str, Some(st.muted), None);
col += d_str.chars().count();
}
}
let mut right_parts: Vec<(String, RgbColor)> = Vec::new();
if base.can_undo || base.can_redo {
let undo_char = if base.can_undo { "↺" } else { "·" };
let redo_char = if base.can_redo { "↻" } else { "·" };
right_parts.push((undo_char.to_string(), st.accent_success));
right_parts.push((" ".to_string(), st.text_primary));
right_parts.push((redo_char.to_string(), st.accent_info));
right_parts.push((" ".to_string(), st.text_primary));
}
if base.is_paused {
right_parts.push(("⏸ PAUSED ".to_string(), st.accent_warning));
}
if w >= 100 {
right_parts.push(("?".to_string(), st.accent_info));
right_parts.push((" help ".to_string(), st.muted));
}
let right_chars: usize = right_parts.iter().map(|(s, _)| s.chars().count()).sum();
let right_start = w.saturating_sub(right_chars);
if right_start > col {
let mut rc = right_start;
for (text, color) in &right_parts {
row.put(rc, text, Some(*color), None);
rc += text.chars().count();
}
}
row
}
fn tune_content_rows(w: usize, st: &PanelStyle, param: &TuneView, now: f32) -> Vec<RowBuf> {
let pulse = breath(now, 4.0, 0.15);
let pulsed_accent = lerp_rgb(st.status_bar_bg, st.accent_active, pulse);
let mut rows = Vec::with_capacity(3);
{
let mut row = RowBuf::new_matte(w, st.status_bar_bg);
let lbl: String = param.label.chars().take(20).collect();
let mut col = 2usize;
row.put(col, &lbl, Some(st.text_primary), None);
col += lbl.chars().count() + 3;
match param.kind {
ParamKind::Action => {
row.put(col, "↵ run", Some(st.accent_active), None);
}
_ => {
let val: String = param.value_text.chars().take(16).collect();
row.put(col, &val, Some(value_color(param.state, st)), None);
}
}
rows.push(row);
}
if param.show_gauge {
let mut row = RowBuf::new_matte(w, st.status_bar_bg);
let gauge_w = (w.saturating_sub(4)).min(60);
let mut g = gauge(param.value, param.range, param.default, gauge_w, st);
for cell in g.iter_mut() {
if cell.1 == st.accent_active {
cell.1 = pulsed_accent;
}
}
row.put_cells(2, &g, None);
rows.push(row);
}
{
let mut row = RowBuf::new_matte(w, st.status_bar_bg);
let hint = match param.kind {
ParamKind::Numeric => footer_hints(&[("←→", "tune"), ("↑↓", "pick"), ("esc", "close")]),
ParamKind::Enum => footer_hints(&[("←→", "cycle"), ("↑↓", "pick"), ("esc", "close")]),
ParamKind::Toggle => footer_hints(&[("↵", "toggle"), ("↑↓", "pick"), ("esc", "close")]),
ParamKind::Action => footer_hints(&[("↵", "run"), ("↑↓", "pick"), ("esc", "close")]),
ParamKind::CliReadonly | ParamKind::Display => {
footer_hints(&[("↑↓", "pick"), ("esc", "close")])
}
};
let start = 2 + center_offset(&hint, w.saturating_sub(2));
row.put(start, &hint, Some(st.muted), None);
rows.push(row);
}
rows
}
fn center_offset(text: &str, width: usize) -> usize {
width.saturating_sub(text.chars().count()) / 2
}
fn wrap_words(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
let mut lines: Vec<String> = Vec::new();
let mut cur = String::new();
let mut cur_len = 0usize;
for word in text.split(' ') {
let wlen = word.chars().count();
if wlen > width {
if !cur.is_empty() {
lines.push(std::mem::take(&mut cur));
cur_len = 0;
}
for ch in word.chars() {
if cur_len == width {
lines.push(std::mem::take(&mut cur));
cur_len = 0;
}
cur.push(ch);
cur_len += 1;
}
continue;
}
let sep = usize::from(!cur.is_empty());
if cur_len + sep + wlen > width {
lines.push(std::mem::take(&mut cur));
cur.push_str(word);
cur_len = wlen;
} else {
if sep == 1 {
cur.push(' ');
}
cur.push_str(word);
cur_len += sep + wlen;
}
}
lines.push(cur);
lines
}
fn msg_content_rows(
w: usize,
st: &PanelStyle,
level: NotificationLevel,
text: &str,
sticky: bool,
) -> Vec<RowBuf> {
let color = level_color(level, st);
let icon = level.icon();
let icon_w = icon.chars().count();
let text_col = 2 + icon_w + 1;
let avail = w.saturating_sub(icon_w + 4).max(1);
let wrapped = wrap_words(text, avail);
let mut rows = Vec::with_capacity(wrapped.len() + 1);
for (i, line) in wrapped.iter().enumerate() {
let mut row = RowBuf::new_matte(w, st.status_bar_bg);
if i == 0 {
row.put(2, icon, Some(color), None);
}
row.put(text_col, line, Some(color), None);
rows.push(row);
}
{
let mut row = RowBuf::new_matte(w, st.status_bar_bg);
if sticky && matches!(level, NotificationLevel::Error) {
let hint = footer_hints(&[("esc", "dismiss")]);
let start = 2 + center_offset(&hint, w.saturating_sub(2));
row.put(start, &hint, Some(st.muted), None);
}
rows.push(row);
}
rows
}
fn pause_content_rows(w: usize, st: &PanelStyle) -> Vec<RowBuf> {
let mut rows = Vec::with_capacity(2);
{
let mut row = RowBuf::new_matte(w, st.status_bar_bg);
row.put(2, "⏸ Paused", Some(st.accent_warning), None);
rows.push(row);
}
{
let mut row = RowBuf::new_matte(w, st.status_bar_bg);
let hint = "space to resume";
let start = 2 + center_offset(hint, w.saturating_sub(2));
row.put(start, hint, Some(st.muted), None);
rows.push(row);
}
rows
}
pub fn build_ambient(
state: &AmbientState,
width: usize,
st: &PanelStyle,
base: &BaseStatus,
now: f32,
) -> RenderedOverlay {
let w = width.max(STRIP_W);
let mut bufs: Vec<RowBuf> = Vec::with_capacity(STRIP_H);
{
let mut border = RowBuf::new_matte(w, st.status_bar_bg);
for c in 0..w {
border.put(c, "▔", Some(st.border_color), None);
}
bufs.push(border);
}
let mut content: Vec<RowBuf> = match state {
AmbientState::Base => vec![base_content_row(w, st, base)],
AmbientState::Tune { param, .. } => tune_content_rows(w, st, param, now),
AmbientState::Msg {
level,
text,
sticky,
..
} => msg_content_rows(w, st, *level, text, *sticky),
};
bufs.append(&mut content);
while bufs.len() < STRIP_H - 1 {
bufs.push(RowBuf::new_matte(w, st.status_bar_bg));
}
bufs.push(RowBuf::new_matte(w, st.status_bar_bg));
debug_assert_eq!(
bufs.len(),
STRIP_H,
"build_ambient must always emit STRIP_H rows"
);
let lines: Vec<String> = bufs.iter().map(|b| b.text()).collect();
let rich_lines: Vec<Vec<RichCell>> = bufs.into_iter().map(|b| b.into_rich()).collect();
RenderedOverlay {
lines,
title_box: None,
rich_lines: Some(rich_lines),
}
}
pub fn build_base_row(width: usize, st: &PanelStyle, base: &BaseStatus) -> RenderedOverlay {
let row = base_content_row(width, st, base);
let line = row.text();
let rich = row.into_rich();
RenderedOverlay {
lines: vec![line],
title_box: None,
rich_lines: Some(vec![rich]),
}
}
fn modal_border_row(left: &str, mid: &str, right: &str, inner: usize, st: &PanelStyle) -> RowBuf {
let mut row = RowBuf::new_matte(inner + 2, st.bg_color);
row.put(0, left, Some(st.border_color), None);
for c in 1..=inner {
row.put(c, mid, Some(st.border_color), None);
}
row.put(inner + 1, right, Some(st.border_color), None);
row
}
pub fn build_ambient_modal(state: &AmbientState, st: &PanelStyle, now: f32) -> RenderedOverlay {
let inner = MODAL_INNER_W;
let content: Vec<RowBuf> = match state {
AmbientState::Tune { param, .. } => tune_content_rows(inner, st, param, now),
AmbientState::Msg {
level,
text,
sticky,
..
} => msg_content_rows(inner, st, *level, text, *sticky),
AmbientState::Base => pause_content_rows(inner, st),
};
let mut bufs: Vec<RowBuf> = Vec::with_capacity(content.len() + 4);
bufs.push(modal_border_row("█", "▀", "█", inner, st));
let mut inner_rows: Vec<RowBuf> = Vec::with_capacity(content.len() + 2);
inner_rows.push(RowBuf::new_matte(inner, st.bg_color));
inner_rows.extend(content);
inner_rows.push(RowBuf::new_matte(inner, st.bg_color));
for r in inner_rows {
let cells = r.into_rich();
let mut line = RowBuf::new_matte(inner + 2, st.bg_color);
line.put(0, "█", Some(st.border_color), None);
for (i, (ch, fg, _bg)) in cells.into_iter().enumerate() {
let mut tmp = [0u8; 4];
line.put(1 + i, ch.encode_utf8(&mut tmp), fg, None);
}
line.put(inner + 1, "█", Some(st.border_color), None);
bufs.push(line);
}
bufs.push(modal_border_row("█", "▄", "█", inner, st));
let lines: Vec<String> = bufs.iter().map(|b| b.text()).collect();
let rich_lines: Vec<Vec<RichCell>> = bufs.into_iter().map(|b| b.into_rich()).collect();
RenderedOverlay {
lines,
title_box: None,
rich_lines: Some(rich_lines),
}
}
pub fn msg(level: NotificationLevel, text: String, now: f32) -> AmbientState {
match level {
NotificationLevel::Info | NotificationLevel::Success => AmbientState::Msg {
level,
text,
sticky: false,
until: now + 1.5,
},
NotificationLevel::Warning => AmbientState::Msg {
level,
text,
sticky: false,
until: now + 2.5,
},
NotificationLevel::Error => AmbientState::Msg {
level,
text,
sticky: true,
until: f32::INFINITY,
},
}
}
fn level_color(level: NotificationLevel, st: &PanelStyle) -> crate::render::palette::RgbColor {
match level {
NotificationLevel::Info => st.accent_info,
NotificationLevel::Success => st.accent_success,
NotificationLevel::Warning => st.accent_warning,
NotificationLevel::Error => st.accent_error,
}
}
#[cfg(test)]
mod ambient_tests {
use super::*;
fn tune_stub() -> TuneView {
TuneView {
label: "Stub Param".to_string(),
value_text: "0.5".to_string(),
value: 0.5,
range: (0.0, 1.0),
default: 0.5,
state: ParamState::Default,
show_gauge: true,
kind: ParamKind::Numeric,
}
}
#[test]
fn error_msg_outranks_everything() {
let states = vec![
AmbientState::Base,
AmbientState::Tune {
param: tune_stub(),
until: 100.0,
},
AmbientState::Msg {
level: NotificationLevel::Error,
text: "boom".into(),
sticky: true,
until: f32::INFINITY,
},
];
assert!(matches!(
resolve(&states, 1.0),
AmbientState::Msg {
level: NotificationLevel::Error,
..
}
));
}
#[test]
fn expired_tune_falls_back_to_base() {
let states = vec![
AmbientState::Base,
AmbientState::Tune {
param: tune_stub(),
until: 5.0,
},
];
assert!(matches!(resolve(&states, 6.0), AmbientState::Base)); }
#[test]
fn nonerror_msg_outranks_tune_while_live() {
let states = vec![
AmbientState::Tune {
param: tune_stub(),
until: 100.0,
},
AmbientState::Msg {
level: NotificationLevel::Success,
text: "saved".into(),
sticky: false,
until: 10.0,
},
];
assert!(matches!(resolve(&states, 1.0), AmbientState::Msg { .. }));
}
#[test]
fn build_ambient_base_emits_strip_h_rows() {
let st = crate::render::theme::GRUVBOX_DARK;
let ov = build_ambient(
&AmbientState::Base,
STRIP_W,
&st,
&BaseStatus::default(),
0.0,
);
assert_eq!(ov.lines.len(), STRIP_H);
assert_eq!(ov.rich_lines.unwrap().len(), STRIP_H);
}
#[test]
fn build_ambient_tune_emits_strip_h_rows() {
let st = crate::render::theme::GRUVBOX_DARK;
let state = AmbientState::Tune {
param: tune_stub(),
until: 100.0,
};
let ov = build_ambient(&state, STRIP_W, &st, &BaseStatus::default(), 0.0);
assert_eq!(ov.lines.len(), STRIP_H);
assert_eq!(ov.rich_lines.unwrap().len(), STRIP_H);
}
#[test]
fn build_ambient_msg_emits_strip_h_rows() {
let st = crate::render::theme::GRUVBOX_DARK;
let state = AmbientState::Msg {
level: NotificationLevel::Error,
text: "something broke".into(),
sticky: true,
until: f32::INFINITY,
};
let ov = build_ambient(&state, STRIP_W, &st, &BaseStatus::default(), 0.0);
assert_eq!(ov.lines.len(), STRIP_H);
assert_eq!(ov.rich_lines.unwrap().len(), STRIP_H);
}
#[test]
fn build_base_row_is_single_row_at_width() {
let st = crate::render::theme::GRUVBOX_DARK;
let ov = build_base_row(80, &st, &BaseStatus::default());
assert_eq!(ov.lines.len(), 1, "base row must be exactly one line");
assert_eq!(ov.lines[0].chars().count(), 80, "base row fills its width");
assert_eq!(ov.rich_lines.as_ref().unwrap().len(), 1);
}
#[test]
fn build_ambient_modal_is_bordered_box() {
let st = crate::render::theme::GRUVBOX_DARK;
let state = AmbientState::Msg {
level: NotificationLevel::Info,
text: "saved".into(),
sticky: false,
until: 10.0,
};
let ov = build_ambient_modal(&state, &st, 0.0);
let top = &ov.lines[0];
let bottom = ov.lines.last().unwrap();
let w = MODAL_INNER_W + 2;
assert!(ov.lines.iter().all(|l| l.chars().count() == w));
assert!(top.starts_with('█') && top.ends_with('█'), "top border");
assert!(top[3..top.len() - 3].contains('▀'), "top half-block fill");
assert!(
bottom.starts_with('█') && bottom.ends_with('█'),
"bottom border"
);
assert!(
bottom[3..bottom.len() - 3].contains('▄'),
"bottom half-block fill"
);
assert!(ov.lines[1..ov.lines.len() - 1]
.iter()
.all(|l| l.starts_with('█') && l.ends_with('█')));
}
#[test]
fn build_ambient_modal_pause_card_from_base() {
let st = crate::render::theme::GRUVBOX_DARK;
let ov = build_ambient_modal(&AmbientState::Base, &st, 0.0);
let joined = ov.lines.join("\n");
assert!(joined.contains("Paused"), "pause card shows Paused");
}
#[test]
fn build_ambient_modal_frame_and_bg_match_controls_panel() {
let st = crate::render::theme::GRUVBOX_DARK;
let state = AmbientState::Msg {
level: NotificationLevel::Info,
text: "saved".into(),
sticky: false,
until: 10.0,
};
let ov = build_ambient_modal(&state, &st, 0.0);
let rich = ov.rich_lines.expect("modal has rich lines");
for row in &rich {
for (ch, fg, bg) in row {
if matches!(ch, '█' | '▀' | '▄') {
assert_eq!(*fg, Some(st.border_color), "frame glyph uses border_color");
}
assert_eq!(
*bg,
Some(st.bg_color),
"modal interior matted with bg_color"
);
}
}
}
#[test]
fn expired_non_sticky_msg_falls_back_to_base() {
let states = vec![
AmbientState::Base,
AmbientState::Msg {
level: NotificationLevel::Info,
text: "gone".into(),
sticky: false,
until: 3.0,
},
];
assert!(matches!(resolve(&states, 10.0), AmbientState::Base));
}
#[test]
fn sticky_msg_survives_past_until() {
let states = vec![
AmbientState::Base,
AmbientState::Msg {
level: NotificationLevel::Warning,
text: "sticky".into(),
sticky: true,
until: 1.0,
},
];
assert!(matches!(resolve(&states, 999.0), AmbientState::Msg { .. }));
}
#[test]
fn build_ambient_tune_contains_label() {
let st = crate::render::theme::GRUVBOX_DARK;
let state = AmbientState::Tune {
param: tune_stub(),
until: 100.0,
};
let ov = build_ambient(&state, STRIP_W, &st, &BaseStatus::default(), 0.0);
let combined: String = ov.lines.concat();
assert!(
combined.contains("Stub Param"),
"TUNE should render the param label"
);
}
#[test]
fn build_ambient_msg_contains_icon_and_text() {
let st = crate::render::theme::GRUVBOX_DARK;
let state = AmbientState::Msg {
level: NotificationLevel::Success,
text: "saved".into(),
sticky: false,
until: 10.0,
};
let ov = build_ambient(&state, STRIP_W, &st, &BaseStatus::default(), 0.0);
let combined: String = ov.lines.concat();
assert!(combined.contains("saved"), "MSG should render text");
assert!(combined.contains('✓'), "Success MSG should render ✓ icon");
}
fn collect_fg_colors(
rich_lines: &[Vec<crate::render::panel::RichCell>],
) -> Vec<crate::render::palette::RgbColor> {
rich_lines
.iter()
.flat_map(|row| row.iter().filter_map(|(_, fg, _)| *fg))
.collect()
}
#[test]
fn base_status_gruvbox_and_nord_differ_in_colors() {
let base = BaseStatus {
preset_name: "Organic".to_string(),
palette_name: "Forest".to_string(),
time_scale_text: "1.0×".to_string(),
population: Some(50_000),
dither_label: None,
can_undo: true,
can_redo: true,
accent: crate::render::palette::RgbColor {
r: 200,
g: 100,
b: 50,
},
is_paused: false,
};
let gruvbox = crate::render::theme::GRUVBOX_DARK;
let nord = crate::render::theme::NORD;
let width = 120;
let ov_gruvbox = build_ambient(&AmbientState::Base, width, &gruvbox, &base, 0.0);
let ov_nord = build_ambient(&AmbientState::Base, width, &nord, &base, 0.0);
let colors_gruvbox = collect_fg_colors(&ov_gruvbox.rich_lines.unwrap());
let colors_nord = collect_fg_colors(&ov_nord.rich_lines.unwrap());
assert_ne!(
colors_gruvbox, colors_nord,
"NORD and GRUVBOX_DARK BASE status must use different fg colors (token-driven)"
);
assert_ne!(
gruvbox.accent_success, nord.accent_success,
"Test precondition: themes must have different accent_success"
);
assert!(
colors_gruvbox.contains(&gruvbox.accent_success),
"Gruvbox BASE should use gruvbox.accent_success for ↺: {:?}",
colors_gruvbox
);
assert!(
colors_nord.contains(&nord.accent_success),
"Nord BASE should use nord.accent_success for ↺: {:?}",
colors_nord
);
}
}
#[cfg(test)]
mod tune_tests {
use super::*;
fn tune_stub() -> TuneView {
TuneView {
label: "Stub Param".to_string(),
value_text: "0.5".to_string(),
value: 0.5,
range: (0.0, 1.0),
default: 0.5,
state: ParamState::Default,
show_gauge: true,
kind: ParamKind::Numeric,
}
}
#[test]
fn rapid_adjust_extends_hold() {
let mut active = AmbientState::Tune {
param: tune_stub(),
until: 2.5,
};
bump_tune(&mut active, 2.0, 2.5); if let AmbientState::Tune { until, .. } = active {
assert!((until - 4.5).abs() < 1e-6); } else {
panic!("expected Tune");
}
}
#[test]
fn surface_tune_pushes_when_absent() {
let mut states = vec![AmbientState::Base];
surface_tune(&mut states, tune_stub(), 1.0, 2.5);
assert_eq!(
states
.iter()
.filter(|s| matches!(s, AmbientState::Tune { .. }))
.count(),
1,
"a fresh Tune is pushed"
);
}
#[test]
fn surface_tune_debounces_existing_tune() {
let mut states = vec![
AmbientState::Base,
AmbientState::Tune {
param: tune_stub(),
until: 2.0,
},
];
surface_tune(&mut states, tune_stub(), 3.0, 2.5);
let tunes: Vec<_> = states
.iter()
.filter_map(|s| match s {
AmbientState::Tune { until, .. } => Some(*until),
_ => None,
})
.collect();
assert_eq!(tunes.len(), 1, "no duplicate Tune");
assert!((tunes[0] - 5.5).abs() < 1e-6, "hold refreshed to now+hold");
}
#[test]
fn surface_tune_drops_redundant_info_echo() {
let mut states = vec![
AmbientState::Base,
AmbientState::Msg {
level: NotificationLevel::Info,
text: "Motion blur: 3".into(),
sticky: false,
until: 100.0,
},
];
surface_tune(&mut states, tune_stub(), 1.0, 2.5);
assert!(
!states.iter().any(|s| matches!(s, AmbientState::Msg { .. })),
"redundant Info echo dropped"
);
assert!(matches!(resolve(&states, 1.0), AmbientState::Tune { .. }));
}
#[test]
fn surface_tune_keeps_relevant_warning() {
let mut states = vec![
AmbientState::Base,
AmbientState::Msg {
level: NotificationLevel::Warning,
text: "Brightness is auto-normalized".into(),
sticky: false,
until: 100.0,
},
];
surface_tune(&mut states, tune_stub(), 1.0, 2.5);
assert!(
states.iter().any(|s| matches!(
s,
AmbientState::Msg {
level: NotificationLevel::Warning,
..
}
)),
"relevant Warning preserved"
);
assert!(matches!(resolve(&states, 1.0), AmbientState::Msg { .. }));
}
#[test]
fn enum_param_omits_gauge_row() {
let st = crate::render::theme::GRUVBOX_DARK;
let mut enum_view = tune_stub();
enum_view.label = "Intensity".into();
enum_view.value_text = "Exponential".into();
enum_view.show_gauge = false;
let state = AmbientState::Tune {
param: enum_view,
until: 100.0,
};
let ov = build_ambient_modal(&state, &st, 0.0);
let combined: String = ov.lines.concat();
assert!(combined.contains("Intensity"), "enum renders label");
assert!(combined.contains("Exponential"), "enum renders value text");
assert!(combined.contains("←→ tune"), "enum keeps the tune hint");
assert!(
!combined.contains('▲'),
"enum param must not render a gauge tick"
);
}
#[test]
fn bump_tune_no_op_on_non_tune() {
let mut base = AmbientState::Base;
bump_tune(&mut base, 10.0, 2.5);
assert!(matches!(base, AmbientState::Base));
}
#[test]
fn build_ambient_tune_contains_value_and_gauge() {
let st = crate::render::theme::GRUVBOX_DARK;
let state = AmbientState::Tune {
param: TuneView {
label: "Sensor Angle".to_string(),
value_text: "45.0°".to_string(),
value: 0.5,
range: (0.0, 1.0),
default: 0.3,
state: ParamState::Modified,
show_gauge: true,
kind: ParamKind::Numeric,
},
until: 100.0,
};
let ov = build_ambient(&state, STRIP_W, &st, &BaseStatus::default(), 0.0);
let combined: String = ov.lines.concat();
assert!(combined.contains("Sensor Angle"), "TUNE renders label");
assert!(combined.contains("45.0°"), "TUNE renders formatted value");
assert!(combined.contains('█'), "gauge filled bar");
assert!(combined.contains('▲'), "gauge default tick");
}
}
#[cfg(test)]
mod msg_tests {
use super::*;
#[test]
fn error_is_sticky() {
let m = AmbientState::Msg {
level: NotificationLevel::Error,
text: "x".into(),
sticky: true,
until: f32::INFINITY,
};
let states = vec![AmbientState::Base, m];
assert!(matches!(resolve(&states, 1e9), AmbientState::Msg { .. }));
}
#[test]
fn success_expires_after_3s() {
let states = vec![
AmbientState::Base,
AmbientState::Msg {
level: NotificationLevel::Success,
text: "s".into(),
sticky: false,
until: 3.0,
},
];
assert!(matches!(resolve(&states, 3.5), AmbientState::Base));
}
#[test]
fn msg_constructor_info_expires_in_1p5s() {
let m = msg(NotificationLevel::Info, "hi".into(), 0.0);
if let AmbientState::Msg { sticky, until, .. } = m {
assert!(!sticky);
assert!((until - 1.5).abs() < 1e-6);
} else {
panic!("expected Msg");
}
}
#[test]
fn msg_constructor_warning_expires_in_2p5s() {
let m = msg(NotificationLevel::Warning, "warn".into(), 0.0);
if let AmbientState::Msg { sticky, until, .. } = m {
assert!(!sticky);
assert!((until - 2.5).abs() < 1e-6);
} else {
panic!("expected Msg");
}
}
#[test]
fn msg_constructor_error_is_sticky() {
let m = msg(NotificationLevel::Error, "err".into(), 0.0);
if let AmbientState::Msg { sticky, until, .. } = m {
assert!(sticky);
assert_eq!(until, f32::INFINITY);
} else {
panic!("expected Msg");
}
}
#[test]
fn wrap_words_keeps_short_text_on_one_line() {
assert_eq!(wrap_words("hi there", 20), vec!["hi there".to_string()]);
}
#[test]
fn wrap_words_breaks_on_word_boundary_not_mid_word() {
let text = "Dither is dev-only - see help-wanted issues on GitHub";
let lines = wrap_words(text, 51);
assert!(lines.len() >= 2, "long text must wrap: {lines:?}");
assert!(
lines.iter().all(|l| l.chars().count() <= 51),
"no line exceeds the width: {lines:?}"
);
assert!(
lines.iter().any(|l| l.contains("GitHub")),
"GitHub must appear intact: {lines:?}"
);
assert_eq!(lines.join(" "), text);
}
#[test]
fn wrap_words_hard_breaks_an_oversized_word() {
let lines = wrap_words("supercalifragilistic", 5);
assert!(lines.iter().all(|l| l.chars().count() <= 5));
assert_eq!(lines.concat(), "supercalifragilistic");
}
#[test]
fn wrap_words_empty_yields_one_empty_line() {
assert_eq!(wrap_words("", 10), vec![String::new()]);
}
#[test]
fn msg_content_rows_does_not_truncate_github() {
let st = crate::render::theme::GRUVBOX_DARK;
let text = "Dither is dev-only - see help-wanted issues on GitHub";
let rows = msg_content_rows(MODAL_INNER_W, &st, NotificationLevel::Info, text, false);
let joined: String = rows.iter().map(|r| r.text()).collect();
assert!(
joined.contains("GitHub"),
"rendered notification must not amputate GitHub:\n{joined}"
);
}
}