use crate::render::controls::registry::{ParamDesc, ParamKind, CATEGORY_NAMES};
use crate::render::palette::RgbColor;
use crate::render::panel::{Padding, PanelBuilder, RenderedOverlay, RichCell, TextAlignment};
use crate::render::theme::PanelStyle;
use crate::render::widgets::{state_color, value_color, RowBuf};
pub use crate::render::widgets::ParamState;
const CW: usize = 66;
const LEFT_W: usize = 24;
const DIVIDER_AT: usize = 25;
const RIGHT_AT: usize = 27;
const RIGHT_W: usize = CW - RIGHT_AT;
pub(crate) const MAX_VISIBLE_ROWS: usize = 10;
pub struct ParamView {
pub desc: ParamDesc,
pub value_text: String,
pub ratio: Option<f32>,
pub def_ratio: Option<f32>,
pub state: ParamState,
}
enum Rk {
Sep,
Buf(RowBuf),
}
fn tab_rows(content_w: usize, active: usize, st: &PanelStyle) -> (RowBuf, RowBuf) {
let labels = CATEGORY_NAMES;
let mut ind: Vec<String> = Vec::new();
let mut lab: Vec<String> = Vec::new();
for (i, l) in labels.iter().enumerate() {
ind.push(format!(
"{:^w$}",
if i == active { '●' } else { '○' },
w = l.len()
));
lab.push(l.to_string());
}
let ind_s = format!("{:^w$}", ind.join(" "), w = content_w);
let lab_s = format!("{:^w$}", lab.join(" "), w = content_w);
let mut ir = RowBuf::new(content_w);
for (c, ch) in ind_s.chars().enumerate() {
let col = if ch == '●' {
Some(st.accent_active)
} else if ch == '○' {
Some(st.muted)
} else {
None
};
ir.put(c, &ch.to_string(), col, None);
}
let mut lr = RowBuf::new(content_w);
let active_l = labels[active];
let lc: Vec<char> = lab_s.chars().collect();
let al: Vec<char> = active_l.chars().collect();
let start = lc.windows(al.len()).position(|w| w == al.as_slice());
for (c, ch) in lc.iter().enumerate() {
let hot = start.is_some_and(|s| c >= s && c < s + al.len());
lr.put(
c,
&ch.to_string(),
Some(if hot { st.accent_active } else { st.muted }),
None,
);
}
(ir, lr)
}
fn legend_row(st: &PanelStyle) -> RowBuf {
const GAP: usize = 3;
let segs: [(&str, RgbColor); 3] = [
("✱ modified", st.accent_modified),
("│ default", st.accent_active),
("─ cli-only", st.cli_color),
];
let total: usize =
segs.iter().map(|(s, _)| s.chars().count()).sum::<usize>() + GAP * (segs.len() - 1);
let mut row = RowBuf::new(CW);
let mut c = CW.saturating_sub(total) / 2;
for (text, color) in segs {
row.put(c, text, Some(color), None);
c += text.chars().count() + GAP;
}
row
}
fn assemble(title: &str, content_w: usize, pad: Padding, rows: Vec<Rk>) -> RenderedOverlay {
let prefix = 1 + pad.top; let offset = 1 + pad.left;
let mut builder = PanelBuilder::new(content_w, None)
.with_padding(Padding::new(pad.top, pad.bottom, pad.left, pad.right))
.with_title(title)
.with_title_box();
let mut bufs: Vec<Option<RowBuf>> = Vec::new();
for r in rows {
match r {
Rk::Sep => {
builder = builder.add_separator();
bufs.push(None);
}
Rk::Buf(b) => {
builder = builder.add_single(b.text(), TextAlignment::Left);
bufs.push(Some(b));
}
}
}
let mut ov = builder.build_overlay();
let rich: Vec<Vec<RichCell>> = ov
.lines
.iter()
.enumerate()
.map(|(li, line)| {
let mut cells: Vec<RichCell> = line.chars().map(|ch| (ch, None, None)).collect();
if li >= prefix {
if let Some(Some(b)) = bufs.get(li - prefix) {
b.overlay_styles(&mut cells, offset);
}
}
cells
})
.collect();
ov.rich_lines = Some(rich);
ov
}
pub fn build_console(
category: usize,
focus: usize,
params: &[ParamView],
style: &PanelStyle,
_accent: RgbColor,
) -> RenderedOverlay {
let cat = category.min(CATEGORY_NAMES.len().saturating_sub(1));
let title = "CONTROLS";
let (ir, lr) = tab_rows(CW, cat, style);
let focus_clamped = if params.is_empty() {
0
} else {
focus.min(params.len() - 1)
};
let mut left: Vec<RowBuf> = Vec::new();
for (i, pv) in params.iter().enumerate() {
let mut b = RowBuf::new(LEFT_W);
let focused = i == focus_clamped;
if focused {
b.set_bg(0..LEFT_W, style.focus_bg);
b.put(0, "▎", Some(style.accent_active), Some(style.focus_bg));
}
let marker = match pv.state {
ParamState::Modified => "✱",
_ => " ",
};
let marker_col = match pv.state {
ParamState::Modified => Some(style.accent_modified),
_ => None,
};
b.put(1, marker, marker_col, None);
let lcol = if focused || pv.state == ParamState::Modified {
style.text_primary
} else {
style.text_secondary
};
b.put(3, pv.desc.label, Some(lcol), None);
left.push(b);
}
debug_assert!(
left.len() <= MAX_VISIBLE_ROWS,
"left pane overflow: {} > {}",
left.len(),
MAX_VISIBLE_ROWS
);
let mut right: Vec<RowBuf> = Vec::new();
if !params.is_empty() {
let pv = ¶ms[focus_clamped];
let mut head = RowBuf::new(RIGHT_W);
head.put(0, pv.desc.label, Some(style.text_primary), None);
let key_str = format!("[{}]", pv.desc.key_hint);
let key_col = match pv.state {
ParamState::Cli => Some(style.cli_color),
_ => Some(style.accent_active),
};
let key_start = RIGHT_W.saturating_sub(key_str.len());
head.put(key_start, &key_str, key_col, None);
right.push(head);
right.push(RowBuf::new(RIGHT_W));
let mut valrow = RowBuf::new(RIGHT_W);
match pv.desc.kind {
ParamKind::Numeric => {
valrow.put(0, &pv.value_text, Some(value_color(pv.state, style)), None);
let state_label = match pv.state {
ParamState::Modified => "modified",
ParamState::Cli => "cli-only",
ParamState::Default | ParamState::Display => "default",
};
let tag_col = pv.value_text.chars().count() + 2;
valrow.put(
tag_col,
state_label,
Some(state_color(pv.state, style)),
None,
);
}
ParamKind::Enum => {
valrow.put(0, &pv.value_text, Some(style.text_primary), None);
let hint = "← → to cycle";
let hint_start = RIGHT_W.saturating_sub(hint.len());
valrow.put(hint_start, hint, Some(style.muted), None);
}
ParamKind::Toggle => {
let (pill_text, pill_col) = if pv.value_text.eq_ignore_ascii_case("on")
|| pv.value_text == "true"
|| pv.value_text == "1"
{
("[ ON ]", style.accent_active)
} else {
("[ OFF ]", style.muted)
};
valrow.put(0, pill_text, Some(pill_col), None);
}
ParamKind::Action => {
valrow.put(0, "↵ to run", Some(style.accent_active), None);
}
ParamKind::CliReadonly => {
valrow.put(0, &pv.value_text, Some(style.cli_color), None);
let hint = "restart to change";
let hint_start = RIGHT_W.saturating_sub(hint.len());
valrow.put(hint_start, hint, Some(style.cli_color), None);
}
ParamKind::Display => {
valrow.put(0, &pv.value_text, Some(style.muted), None);
}
}
right.push(valrow);
match pv.desc.kind {
ParamKind::Numeric => {
if let (Some(ratio), Some(def_ratio)) = (pv.ratio, pv.def_ratio) {
let gw = RIGHT_W.saturating_sub(2);
let mut grow = RowBuf::new(RIGHT_W);
grow.put_cells(
0,
&crate::render::controls::value::gauge(
ratio,
def_ratio,
gw,
state_color(pv.state, style),
style.accent_active,
style.muted,
),
None,
);
right.push(grow);
let mut tick = RowBuf::new(RIGHT_W);
let min_s = "min";
let max_s = "max";
tick.put(0, min_s, Some(style.muted), None);
let cur_col = (ratio * (gw as f32 - 1.0)).round() as usize;
let cur_col = cur_col.min(gw.saturating_sub(1));
tick.put(cur_col, "▲", Some(style.accent_active), None);
let max_start = RIGHT_W.saturating_sub(max_s.len());
tick.put(max_start, max_s, Some(style.muted), None);
right.push(tick);
} else {
right.push(RowBuf::new(RIGHT_W));
right.push(RowBuf::new(RIGHT_W));
}
}
ParamKind::Enum => {
right.push(RowBuf::new(RIGHT_W));
right.push(RowBuf::new(RIGHT_W));
}
ParamKind::Toggle => {
let mut trow = RowBuf::new(RIGHT_W);
trow.put(0, "press key to toggle", Some(style.muted), None);
right.push(trow);
right.push(RowBuf::new(RIGHT_W));
}
ParamKind::Action => {
let mut arow = RowBuf::new(RIGHT_W);
arow.put(0, "no undo — runs immediately", Some(style.muted), None);
right.push(arow);
right.push(RowBuf::new(RIGHT_W));
}
ParamKind::CliReadonly => {
let mut crow = RowBuf::new(RIGHT_W);
crow.put(0, "set via CLI flag at launch", Some(style.muted), None);
right.push(crow);
right.push(RowBuf::new(RIGHT_W));
}
ParamKind::Display => {
right.push(RowBuf::new(RIGHT_W));
right.push(RowBuf::new(RIGHT_W));
}
}
}
debug_assert!(
right.len() <= MAX_VISIBLE_ROWS,
"right pane overflow: {} > {}",
right.len(),
MAX_VISIBLE_ROWS
);
let body_h = MAX_VISIBLE_ROWS;
while left.len() < body_h {
left.push(RowBuf::new(LEFT_W));
}
while right.len() < body_h {
right.push(RowBuf::new(RIGHT_W));
}
let mut rows: Vec<Rk> = vec![Rk::Buf(ir), Rk::Buf(lr), Rk::Sep];
for r in 0..body_h {
let mut row = RowBuf::new(CW);
row.blit(0, &left[r]);
row.put(DIVIDER_AT, "│", Some(style.muted), None);
row.blit(RIGHT_AT, &right[r]);
rows.push(Rk::Buf(row));
}
rows.push(Rk::Buf(RowBuf::new(CW)));
rows.push(Rk::Sep);
rows.push(Rk::Buf(legend_row(style)));
assemble(title, CW, Padding::new(1, 0, 2, 2), rows)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::controls::registry::{
visible_params, ParamDesc, ParamId, ParamKind, RegistryCtx, CATEGORY_NAMES,
};
#[test]
fn no_category_exceeds_max_visible_rows() {
const ALL_CTX: [RegistryCtx; 4] = [
RegistryCtx {
diffusion_gaussian: false,
mouse_enabled: false,
},
RegistryCtx {
diffusion_gaussian: false,
mouse_enabled: true,
},
RegistryCtx {
diffusion_gaussian: true,
mouse_enabled: false,
},
RegistryCtx {
diffusion_gaussian: true,
mouse_enabled: true,
},
];
for ctx in &ALL_CTX {
for (cat, name) in CATEGORY_NAMES.iter().enumerate() {
let n = visible_params(cat, ctx).len();
assert!(
n <= MAX_VISIBLE_ROWS,
"category {} ({}) has {} rows > MAX_VISIBLE_ROWS ({}) at ctx {:?}",
cat,
name,
n,
MAX_VISIBLE_ROWS,
ctx,
);
}
}
}
fn fixture_params(cat: usize) -> Vec<ParamView> {
let make = |id: ParamId,
key_hint: &'static str,
label: &'static str,
kind: ParamKind,
value_text: &'static str,
ratio: Option<f32>,
def_ratio: Option<f32>,
state: ParamState|
-> ParamView {
ParamView {
desc: ParamDesc {
id,
key_hint,
label,
kind,
},
value_text: value_text.to_string(),
ratio,
def_ratio,
state,
}
};
match cat {
0 => vec![
make(
ParamId::SensorAngle,
"A/a",
"Sensor Angle",
ParamKind::Numeric,
"30.0°",
Some(0.5),
Some(0.3),
ParamState::Modified,
),
make(
ParamId::TurnAngle,
"T/t",
"Turn Angle",
ParamKind::Numeric,
"45.0°",
Some(0.9),
Some(0.9),
ParamState::Default,
),
],
1 => vec![make(
ParamId::DiffusionKernel,
"K",
"Diffusion",
ParamKind::Enum,
"Box",
None,
None,
ParamState::Default,
)],
2 => vec![make(
ParamId::Palette,
"c/C",
"Palette",
ParamKind::Enum,
"Aurora",
None,
None,
ParamState::Default,
)],
3 => vec![make(
ParamId::Brightness,
"N/n",
"Brightness",
ParamKind::Numeric,
"100",
Some(0.5),
Some(0.5),
ParamState::Default,
)],
4 => vec![make(
ParamId::Population,
"─",
"Population",
ParamKind::CliReadonly,
"50k",
None,
None,
ParamState::Cli,
)],
5 => vec![make(
ParamId::Reset,
"0",
"Reset",
ParamKind::Action,
"",
None,
None,
ParamState::Default,
)],
_ => vec![],
}
}
#[test]
fn console_dims_constant_across_categories_and_focus() {
let s = crate::render::theme::SLIME_DARK;
let acc = crate::render::palette::RgbColor { r: 0, g: 200, b: 0 };
let mk = |cat| build_console(cat, 0, &fixture_params(cat), &s, acc);
let h0 = mk(0).lines.len();
for cat in 0..6 {
assert_eq!(mk(cat).lines.len(), h0, "category {cat} height differs");
}
let w0 = mk(0).lines[0].chars().count();
for cat in 0..6 {
let ov = build_console(cat, 0, &fixture_params(cat), &s, acc);
assert!(
ov.lines.iter().all(|l| l.chars().count() == w0),
"cat {cat} width varies"
);
}
if fixture_params(0).len() >= 2 {
let params_cat0 = fixture_params(0);
for focus in 0..params_cat0.len() {
let ov = build_console(0, focus, ¶ms_cat0, &s, acc);
assert_eq!(ov.lines.len(), h0, "cat 0 focus {focus} height differs");
assert!(
ov.lines.iter().all(|l| l.chars().count() == w0),
"cat 0 focus {focus} width varies"
);
}
}
}
#[test]
fn console_has_title_box_and_active_tab() {
let ov = build_console(
0,
0,
&fixture_params(0),
&crate::render::theme::SLIME_DARK,
crate::render::palette::RgbColor { r: 0, g: 200, b: 0 },
);
assert!(ov
.title_box
.as_ref()
.unwrap()
.lines
.iter()
.any(|l| l.contains("CONTROLS")));
assert!(ov.lines.iter().any(|l| l.contains('●')));
}
#[test]
fn console_right_pane_shows_focused_param_label() {
let params = fixture_params(0);
let ov = build_console(
0,
0,
¶ms,
&crate::render::theme::SLIME_DARK,
crate::render::palette::RgbColor { r: 0, g: 200, b: 0 },
);
let combined: String = ov.lines.concat();
assert!(
combined.contains("Sensor Angle"),
"focused label not found in overlay"
);
}
#[test]
fn console_focus_clamps_to_params_len() {
let params = fixture_params(5); let ov = build_console(
5,
99,
¶ms,
&crate::render::theme::SLIME_DARK,
crate::render::palette::RgbColor { r: 0, g: 200, b: 0 },
);
assert!(!ov.lines.is_empty());
}
#[test]
fn console_empty_params_does_not_panic() {
let s = crate::render::theme::SLIME_DARK;
let acc = crate::render::palette::RgbColor { r: 0, g: 200, b: 0 };
let ov_empty = build_console(0, 0, &[], &s, acc);
let ov_normal = build_console(0, 0, &fixture_params(0), &s, acc);
assert_eq!(ov_empty.lines.len(), ov_normal.lines.len());
}
fn enum_fixture() -> ParamView {
ParamView {
desc: ParamDesc {
id: ParamId::DiffusionKernel,
key_hint: "K",
label: "Diffusion",
kind: ParamKind::Enum,
},
value_text: "Gaussian".to_string(),
ratio: None,
def_ratio: None,
state: ParamState::Default,
}
}
#[test]
fn focused_row_has_bg_override() {
let mut style = crate::render::theme::SLIME_DARK;
let focus_bg = RgbColor::new(1, 2, 3);
style.focus_bg = focus_bg;
let ov = build_console(
0,
2,
&fixture_params(0),
&style,
crate::render::palette::RgbColor { r: 0, g: 200, b: 0 },
);
let rich = ov.rich_lines.unwrap();
assert_eq!(
rich.iter()
.flatten()
.filter(|(_, _, bg)| *bg == Some(focus_bg))
.count(),
LEFT_W,
"focused row must use the supplied focus_bg token"
);
}
#[test]
fn enum_param_shows_value_not_gauge_ticks() {
let pv = enum_fixture();
let ov = build_console(
1,
0,
&[pv],
&crate::render::theme::SLIME_DARK,
crate::render::palette::RgbColor { r: 0, g: 200, b: 0 },
);
assert!(
ov.lines.iter().any(|l| l.contains("Gaussian")),
"enum value text 'Gaussian' not found in overlay"
);
let combined: String = ov.lines.concat();
assert!(
!combined.contains('▲'),
"enum detail pane must not show gauge tick marker ▲"
);
}
#[test]
fn action_param_detail_content() {
let ov = build_console(
5,
0,
&fixture_params(5),
&crate::render::theme::SLIME_DARK,
crate::render::palette::RgbColor { r: 0, g: 200, b: 0 },
);
let combined: String = ov.lines.concat();
assert!(
combined.contains("↵ to run"),
"action detail should contain '↵ to run' affordance"
);
}
#[test]
fn cli_readonly_param_detail_content() {
let mut style = crate::render::theme::SLIME_DARK;
let cli_color = RgbColor::new(1, 2, 3);
style.cli_color = cli_color;
let ov = build_console(
4,
0,
&fixture_params(4),
&style,
crate::render::palette::RgbColor { r: 0, g: 200, b: 0 },
);
let combined: String = ov.lines.concat();
assert!(
combined.contains("restart to change"),
"cli-readonly detail should contain 'restart to change' hint"
);
assert!(
ov.rich_lines
.expect("console needs rich_lines")
.iter()
.flatten()
.any(|(_, fg, _)| *fg == Some(cli_color)),
"CLI content must use the supplied cli_color token"
);
}
#[test]
fn toggle_param_detail_content_on() {
let pv = ParamView {
desc: ParamDesc {
id: ParamId::Invert,
key_hint: "X",
label: "Invert",
kind: ParamKind::Toggle,
},
value_text: "on".to_string(),
ratio: None,
def_ratio: None,
state: ParamState::Default,
};
let ov = build_console(
2,
0,
&[pv],
&crate::render::theme::SLIME_DARK,
crate::render::palette::RgbColor { r: 0, g: 200, b: 0 },
);
let combined: String = ov.lines.concat();
assert!(
combined.contains("[ ON ]"),
"toggle detail with 'on' value should contain '[ ON ]' pill"
);
}
#[test]
fn toggle_param_detail_content_off() {
let pv = ParamView {
desc: ParamDesc {
id: ParamId::Invert,
key_hint: "X",
label: "Invert",
kind: ParamKind::Toggle,
},
value_text: "off".to_string(),
ratio: None,
def_ratio: None,
state: ParamState::Default,
};
let ov = build_console(
2,
0,
&[pv],
&crate::render::theme::SLIME_DARK,
crate::render::palette::RgbColor { r: 0, g: 200, b: 0 },
);
let combined: String = ov.lines.concat();
assert!(
combined.contains("[ OFF ]"),
"toggle detail with 'off' value should contain '[ OFF ]' pill"
);
}
#[test]
fn numeric_param_has_gauge_bar_char() {
let ov = build_console(
0,
0,
&fixture_params(0),
&crate::render::theme::SLIME_DARK,
crate::render::palette::RgbColor { r: 0, g: 200, b: 0 },
);
let combined: String = ov.lines.concat();
assert!(
combined.contains('█'),
"numeric detail pane should contain gauge bar fill character █"
);
}
}