use std::collections::HashMap;
use crate::gui::actions::Action;
use crate::gui::app::CellaApp;
use crate::gui::theme::section;
use cella_lib::{GridState, ParamDesc, ParamKind, ParamValue};
impl CellaApp {
pub(in crate::gui) fn ui_model_params(&mut self, ui: &mut egui::Ui) {
let Some(model) = self.scenario.d2.as_ref().and_then(|g| g.model.as_ref()) else {
return;
};
let title = model.typetag_name().to_string();
let descs = model.params();
let values: HashMap<String, ParamValue> = descs
.iter()
.filter_map(|d| Some((d.key.clone(), model.get_param(&d.key)?)))
.collect();
let groups = group_params(descs);
let mut commit: Option<(String, ParamValue)> = None;
section(ui, &title, |ui| {
for (group, descs) in &groups {
if let Some(name) = group {
ui.separator();
ui.strong(name);
}
for desc in descs {
let Some(value) = values.get(&desc.key) else {
continue;
};
if let Some(edited) = param_control(ui, desc, value) {
commit = Some((desc.key.clone(), edited));
}
}
}
});
if let Some((key, value)) = commit {
self.push(Action::ApplyModelParam { key, value });
}
}
pub(in crate::gui) fn apply_model_param(&mut self, key: &str, value: ParamValue) {
let Some(grid) = self.scenario.d2.as_mut() else {
return;
};
match grid.set_model_param(key, value.clone()) {
Ok(()) => self.mirror_param_into_initial_state(key, value),
Err(e) => self.set_status(format!("Model parameter error: {e}")),
}
}
pub(in crate::gui) fn mirror_param_into_initial_state(&mut self, key: &str, value: ParamValue) {
if let Some(GridState::D2 {
model: Some(snapshot),
..
}) = self.scenario.initial_state.as_mut()
{
let _ = snapshot.set_param(key, value);
}
}
}
fn group_params(descs: Vec<ParamDesc>) -> Vec<(Option<String>, Vec<ParamDesc>)> {
let mut out: Vec<(Option<String>, Vec<ParamDesc>)> = Vec::new();
for desc in descs {
match out.iter_mut().find(|(group, _)| *group == desc.group) {
Some((_, bucket)) => bucket.push(desc),
None => out.push((desc.group.clone(), vec![desc])),
}
}
if let Some(i) = out.iter().position(|(group, _)| group.is_none()) {
let ungrouped = out.remove(i);
out.insert(0, ungrouped);
}
out
}
fn commit_on(desc: &ParamDesc, changed: bool, ended: bool) -> bool {
if desc.read_only {
return false;
}
if desc.reattach { ended } else { changed }
}
fn gesture_ended(changed: bool, dragged: bool, drag_stopped: bool, lost_focus: bool) -> bool {
drag_stopped || lost_focus || (changed && !dragged)
}
fn commit_now(desc: &ParamDesc, resp: &egui::Response, edited: bool) -> bool {
let ended = gesture_ended(
resp.changed() || edited,
resp.dragged(),
resp.drag_stopped(),
resp.lost_focus(),
);
edited && commit_on(desc, edited, ended)
}
fn param_control(ui: &mut egui::Ui, desc: &ParamDesc, value: &ParamValue) -> Option<ParamValue> {
if desc.read_only {
with_help(
ui.label(format!("{}: {}", desc.label, value_text(value))),
desc,
);
return None;
}
match (&desc.kind, value) {
(ParamKind::Float { min, max, step }, ParamValue::Float(current)) => {
let mut v = *current;
let mut slider = egui::Slider::new(&mut v, *min..=*max)
.step_by(*step)
.clamping(egui::SliderClamping::Edits)
.text(&desc.label);
if let Some(unit) = &desc.unit {
slider = slider.suffix(unit);
}
let resp = with_help(ui.add(slider), desc);
commit_now(desc, &resp, v != *current).then_some(ParamValue::Float(v))
}
(ParamKind::Int { min, max }, ParamValue::Int(current)) => {
let mut v = *current;
let resp = ui
.horizontal(|ui| {
let resp = ui.add(
egui::DragValue::new(&mut v)
.range(*min..=*max)
.clamp_existing_to_range(false),
);
ui.label(&desc.label);
resp
})
.inner;
let resp = with_help(resp, desc);
commit_now(desc, &resp, v != *current).then_some(ParamValue::Int(v))
}
(ParamKind::Bool, ParamValue::Bool(current)) => {
let mut v = *current;
let resp = with_help(ui.checkbox(&mut v, &desc.label), desc);
commit_now(desc, &resp, v != *current).then_some(ParamValue::Bool(v))
}
(ParamKind::Choice { options }, ParamValue::Choice(current)) => {
let mut sel = current.clone();
let resp = with_help(
egui::ComboBox::from_label(&desc.label)
.selected_text(sel.clone())
.show_ui(ui, |ui| {
for opt in options {
ui.selectable_value(&mut sel, opt.clone(), opt);
}
})
.response,
desc,
);
commit_now(desc, &resp, sel != *current).then_some(ParamValue::Choice(sel))
}
(ParamKind::Bits { len }, ParamValue::Bits(current)) => {
let id = ui.make_persistent_id(("bits", &desc.key));
let mut text = ui
.data_mut(|d| d.get_temp::<String>(id))
.unwrap_or_else(|| format!("{current:#x}"));
let resp = ui
.horizontal(|ui| {
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(160.0));
ui.label(format!("{} ({len} bits; hex, binary or decimal)", desc.label));
r
})
.inner;
let resp = with_help(resp, desc);
let parsed = parse_bits(&text).filter(|v| bits_fit(*v, *len));
if resp.has_focus() {
ui.data_mut(|d| d.insert_temp(id, text));
} else {
ui.data_mut(|d| d.remove::<String>(id));
}
match parsed {
Some(v) if v != *current && resp.lost_focus() && !desc.read_only => {
Some(ParamValue::Bits(v))
}
_ => None,
}
}
_ => None,
}
}
fn parse_bits(text: &str) -> Option<u128> {
let t: String = text.trim().chars().filter(|c| *c != '_').collect();
let lower = t.to_ascii_lowercase();
if let Some(hex) = lower.strip_prefix("0x") {
u128::from_str_radix(hex, 16).ok()
} else if let Some(bin) = lower.strip_prefix("0b") {
u128::from_str_radix(bin, 2).ok()
} else {
lower.parse::<u128>().ok()
}
}
fn bits_fit(v: u128, len: u32) -> bool {
len >= 128 || (v >> len) == 0
}
fn with_help(resp: egui::Response, desc: &ParamDesc) -> egui::Response {
match &desc.help {
Some(help) => resp.on_hover_text(help),
None => resp,
}
}
pub(in crate::gui) fn value_text(value: &ParamValue) -> String {
match value {
ParamValue::Float(v) => v.to_string(),
ParamValue::Int(v) => v.to_string(),
ParamValue::Bool(v) => v.to_string(),
ParamValue::Choice(v) => v.clone(),
ParamValue::Bits(v) => format!("{v:#x}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn desc(key: &str, group: Option<&str>, reattach: bool, read_only: bool) -> ParamDesc {
ParamDesc {
key: key.to_string(),
label: key.to_string(),
group: group.map(str::to_string),
help: None,
unit: None,
kind: ParamKind::Bool,
reattach,
read_only,
}
}
fn shape(groups: &[(Option<String>, Vec<ParamDesc>)]) -> Vec<(Option<String>, Vec<String>)> {
groups
.iter()
.map(|(g, ds)| (g.clone(), ds.iter().map(|d| d.key.clone()).collect()))
.collect()
}
#[test]
fn group_params_returns_no_buckets_for_no_parameters() {
assert!(group_params(Vec::new()).is_empty());
}
#[test]
fn group_params_puts_every_ungrouped_parameter_in_one_bucket_in_order() {
let out = group_params(vec![
desc("a", None, false, false),
desc("b", None, false, false),
]);
assert_eq!(
shape(&out),
vec![(None, vec!["a".to_string(), "b".to_string()])]
);
}
#[test]
fn group_params_lists_the_ungrouped_bucket_first_even_when_it_appears_last() {
let out = group_params(vec![
desc("a", Some("Wind"), false, false),
desc("b", None, false, false),
]);
assert_eq!(
shape(&out),
vec![
(None, vec!["b".to_string()]),
(Some("Wind".to_string()), vec!["a".to_string()]),
]
);
}
#[test]
fn group_params_keeps_interleaved_groups_in_first_appearance_order() {
let out = group_params(vec![
desc("a1", Some("A"), false, false),
desc("b1", Some("B"), false, false),
desc("a2", Some("A"), false, false),
desc("b2", Some("B"), false, false),
]);
assert_eq!(
shape(&out),
vec![
(
Some("A".to_string()),
vec!["a1".to_string(), "a2".to_string()]
),
(
Some("B".to_string()),
vec!["b1".to_string(), "b2".to_string()]
),
]
);
}
#[test]
fn a_cheap_parameter_commits_as_soon_as_the_widget_changes() {
let cheap = desc("a", None, false, false);
assert!(commit_on(&cheap, true, false), "changed alone is enough");
assert!(commit_on(&cheap, true, true));
assert!(!commit_on(&cheap, false, true), "no change, nothing to do");
assert!(!commit_on(&cheap, false, false));
}
#[test]
fn an_expensive_parameter_waits_until_the_gesture_ends() {
let expensive = desc("a", None, true, false);
assert!(
!commit_on(&expensive, true, false),
"mid-drag changes are skipped so the rebuild runs once"
);
assert!(commit_on(&expensive, true, true));
assert!(commit_on(&expensive, false, true), "release always commits");
assert!(!commit_on(&expensive, false, false));
}
#[test]
fn any_change_outside_a_drag_ends_the_gesture_by_itself() {
assert!(
gesture_ended(true, false, false, false),
"an arrow-key edit is a whole gesture on its own"
);
assert!(
!gesture_ended(true, true, false, false),
"a change under the pointer is mid-drag, so it waits"
);
assert!(
gesture_ended(true, true, true, false),
"the frame the drag ends on commits it"
);
assert!(
gesture_ended(false, false, true, false),
"releasing the slider ends the gesture"
);
assert!(
gesture_ended(false, false, false, true),
"so does clicking away from it"
);
assert!(
!gesture_ended(false, false, false, false),
"an untouched frame is not the end of anything"
);
}
#[test]
fn read_only_values_read_as_plain_text() {
assert_eq!(value_text(&ParamValue::Float(7.5)), "7.5");
assert_eq!(value_text(&ParamValue::Int(42)), "42");
assert_eq!(value_text(&ParamValue::Bool(true)), "true");
assert_eq!(value_text(&ParamValue::Choice("Moore".into())), "Moore");
assert_eq!(value_text(&ParamValue::Bits(30)), "0x1e");
}
#[test]
fn parse_bits_reads_hex_binary_and_decimal_and_rejects_junk() {
assert_eq!(parse_bits("30"), Some(30));
assert_eq!(parse_bits(" 0x1E "), Some(30));
assert_eq!(parse_bits("0b1_1110"), Some(30));
assert_eq!(parse_bits(&u128::MAX.to_string()), Some(u128::MAX));
assert_eq!(parse_bits(""), None);
assert_eq!(parse_bits("thirty"), None);
assert_eq!(parse_bits("0x"), None);
assert_eq!(parse_bits("-1"), None);
}
#[test]
fn bits_fit_checks_the_table_width() {
assert!(bits_fit(255, 8));
assert!(!bits_fit(256, 8));
assert!(bits_fit(u128::MAX, 128));
assert!(bits_fit(0, 1));
assert!(!bits_fit(2, 1));
}
#[test]
fn an_untouched_control_of_any_kind_commits_nothing() {
let float = ParamDesc {
kind: ParamKind::Float {
min: 0.0,
max: 10.0,
step: 0.5,
},
unit: Some("m/s".into()),
help: Some("a tooltip".into()),
..desc("float", Some("Group"), false, false)
};
let int = ParamDesc {
kind: ParamKind::Int { min: 0, max: 10 },
..desc("int", None, true, false)
};
let boolean = desc("bool", None, false, false);
let choice = ParamDesc {
kind: ParamKind::Choice {
options: vec!["one".into(), "two".into()],
},
..desc("choice", None, false, false)
};
let bits = ParamDesc {
kind: ParamKind::Bits { len: 8 },
..desc("bits", None, false, false)
};
let locked = ParamDesc {
kind: ParamKind::Int { min: 0, max: 10 },
..desc("locked", None, false, true)
};
let mismatched = (&float, ParamValue::Choice("nonsense".into()));
let rows = [
(&float, ParamValue::Float(1.0)),
(&int, ParamValue::Int(3)),
(&boolean, ParamValue::Bool(true)),
(&choice, ParamValue::Choice("one".into())),
(&bits, ParamValue::Bits(30)),
(&locked, ParamValue::Int(7)),
mismatched,
];
let mut committed = Vec::new();
egui::__run_test_ui(|ui| {
committed = rows
.iter()
.map(|(desc, value)| param_control(ui, desc, value))
.collect();
});
assert_eq!(committed, vec![None; rows.len()]);
}
#[test]
fn a_value_off_the_step_grid_or_outside_the_range_is_shown_not_rewritten() {
let off_grid = ParamDesc {
kind: ParamKind::Float {
min: 0.0,
max: 1.0,
step: 0.005,
},
..desc("off_grid", None, false, false)
};
let out_of_range = ParamDesc {
kind: ParamKind::Int { min: 1, max: 1000 },
..desc("out_of_range", None, false, false)
};
let mut committed = Vec::new();
egui::__run_test_ui(|ui| {
committed = vec![
param_control(ui, &off_grid, &ParamValue::Float(0.131)),
param_control(ui, &out_of_range, &ParamValue::Int(1500)),
];
});
assert_eq!(
committed,
vec![None, None],
"an untouched control must not commit a value the widget invented"
);
}
#[test]
fn a_read_only_parameter_never_commits() {
for reattach in [false, true] {
let locked = desc("a", None, reattach, true);
for changed in [false, true] {
for drag_stopped in [false, true] {
assert!(
!commit_on(&locked, changed, drag_stopped),
"read-only parameters are never written"
);
}
}
}
}
}