use egui::containers::menu::{MenuButton, MenuConfig};
use egui::{Button, Checkbox, DragValue, Id, Label, Layout, Popup, PopupCloseBehavior, RichText, TextEdit, vec2};
use henad_core::explore::factor::LevelSpec;
use henad_core::explore::value::{format_value, parse_value};
use henad_core::params::{ParamDescriptor, ParamKind, ParamValue};
use crate::icons::material_design_icons::{MDI_TABLE_COLUMN, MDI_TUNE_VERTICAL};
use crate::ui::params::display_value;
use crate::ui::plural;
use crate::ui::sweep::builder::FormInput;
use crate::ui::sweep::draft::{
DraftDesign, DraftIssue, DraftMode, DraftSite, FactorDraft, IssueKind, LevelPreview, levels_example, number_text,
parse_levels, round_step, whole_range_text,
};
use crate::ui::sweep::layout::{FormRows, Note, column, icon_button, note_label, note_line, segmented, slot};
pub const BASELINE_HEADER: &str = "Parameters tab";
const EDITOR_WIDTH: f32 = 280.0;
const EDITOR_BUTTON_WIDTH: f32 = 28.0;
const MAX_NOTE_VALUES: usize = 6;
const NOTE_HEAD_VALUES: usize = 3;
pub fn levels_tooltip(integer: bool, draws_ranges: bool) -> &'static str {
match (integer, draws_ranges) {
(_, true) => "Enter comma-separated values, min:max:step with both ends included, or min:max to draw from",
(true, false) => {
"Enter comma-separated values or min:max:step, both ends included. Leave out the step to step by 1."
}
(false, false) => {
"Enter comma-separated values or min:max:step, both ends included. Use min:max under Latin hypercube or \
Uniform random."
}
}
}
pub const SEARCH_LEVELS_TOOLTIP: &str = "Enter a range as min:max to search, or comma-separated values to select from";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LevelNoun {
Value,
Option,
Tick,
}
impl LevelNoun {
pub fn of(kind: &ParamKind) -> Self {
match kind {
ParamKind::Bool { .. } | ParamKind::Choice { .. } => Self::Option,
ParamKind::F32 { .. } | ParamKind::U32 { .. } => Self::Value,
}
}
fn word(self) -> &'static str {
match self {
Self::Value | Self::Option => "value",
Self::Tick => "tick",
}
}
}
pub fn preview_note(preview: &LevelPreview, noun: LevelNoun, search: bool) -> String {
match preview {
LevelPreview::Listed { count, values, last } => {
let listed = if *count <= MAX_NOTE_VALUES {
values.join(", ")
} else {
let head: Vec<&str> = values.iter().take(NOTE_HEAD_VALUES).map(String::as_str).collect();
format!("{}, …, {last}", head.join(", "))
};
let count_noun = match noun {
LevelNoun::Option => plural(*count as u64, "option"),
LevelNoun::Value | LevelNoun::Tick => plural(*count as u64, noun.word()),
};
let among = if search && noun != LevelNoun::Option {
" to select from"
} else {
""
};
format!("{count} {count_noun}{among}: {listed}")
}
LevelPreview::Drawn { min, max } if search => format!("Any {} from {min} to {max}", noun.word()),
LevelPreview::Drawn { min, max } => {
format!("Any {} from {min} to {max}, drawn by the design", noun.word())
}
}
}
pub fn preview_tooltip(preview: &LevelPreview) -> Option<String> {
let LevelPreview::Listed { count, values, .. } = preview else {
return None;
};
if *count <= MAX_NOTE_VALUES {
return None;
}
let mut text = values.join(", ");
if *count > values.len() {
text.push_str(&format!(" and {} more", count - values.len()));
}
Some(text)
}
pub fn format_hint(noun: LevelNoun, draws_ranges: bool) -> String {
let example = match noun {
LevelNoun::Tick => "100, 200",
LevelNoun::Value | LevelNoun::Option => "0.1, 0.2",
};
if draws_ranges {
format!("Format: min:max or {example}")
} else {
format!("Format: {example} or min:max:step")
}
}
pub fn row_note(
issue: Option<&DraftIssue>,
preview: Option<&LevelPreview>,
noun: LevelNoun,
search: bool,
draws_ranges: bool,
held_back: bool,
) -> Note {
match issue {
Some(issue) if issue.kind == IssueKind::Invalid && held_back => Note::Weak(format_hint(noun, draws_ranges)),
Some(issue) => Note::Issue(issue.kind, issue.message.clone()),
None => preview.map_or(Note::Empty, |preview| Note::Weak(preview_note(preview, noun, search))),
}
}
pub fn legend(mode: DraftMode, design: DraftDesign) -> &'static str {
match (mode, design) {
(DraftMode::Search, _) => {
"Enter a range as 0.1:0.5 to search all of it, or values as 0.1, 0.2 to select from. Checkbox and \
dropdown parameters use all options by default."
}
(DraftMode::Sweep, DraftDesign::Table) => {
"The design table sets the parameters it names. Other parameters use values from the Parameters tab."
}
(DraftMode::Sweep, DraftDesign::LatinHypercube | DraftDesign::UniformRandom) => {
"Enter a range as 0.1:0.5 for the design to draw from, or values as 0.1, 0.2."
}
(DraftMode::Sweep, DraftDesign::EveryCombination | DraftDesign::Zip | DraftDesign::VaryEachAlone) => {
"Enter values as 0.1, 0.2, or a range as 0.1:0.5:0.1 with both ends included. Leave out the step of an \
integer range to step by 1."
}
}
}
pub fn parameters_section(
ui: &mut egui::Ui,
rows: &mut FormRows,
draft: &mut crate::ui::sweep::draft::SweepDraft,
input: &FormInput<'_>,
) {
let schema = input.schema;
if schema.params.is_empty() {
ui.weak("This model has no parameters.");
return;
}
ui.add(Label::new(RichText::new(legend(draft.mode, draft.design)).weak()).wrap());
let layout = rows.layout;
if let Some(baseline_width) = layout.baseline_width {
ui.with_layout(Layout::left_to_right(egui::Align::Min), |ui| {
let weak = |text: &str| RichText::new(text).weak();
slot(ui, layout.label_width, |ui| ui.label(weak("Parameter")));
slot(ui, baseline_width, |ui| ui.label(weak(BASELINE_HEADER)));
slot(ui, layout.values_width(), |ui| ui.label(weak("Values")));
});
}
let table_columns = draft
.runs_table()
.then(|| draft.table.as_ref().map(|table| table.columns()).unwrap_or_default());
let context = RowContext {
search: draft.mode == DraftMode::Search,
draws_ranges: draft.draws_ranges(),
cli_command: input.cli_command,
};
for (index, descriptor) in schema.params.iter().enumerate() {
let Some(factor) = draft.factors.get_mut(index) else {
continue;
};
let row = ParameterRow {
model_id: schema.id,
index,
descriptor,
panel_value: input.panel_values.get(index),
in_table: table_columns
.as_ref()
.map(|columns| columns.iter().any(|column| column == descriptor.id)),
preview: input.summary.param_previews.get(index).and_then(Option::as_ref),
issue: input.summary.issues_at(DraftSite::Factor(index)).next(),
};
parameter_row(ui, rows, context, &row, factor);
}
}
#[derive(Debug, Clone, Copy)]
struct RowContext<'a> {
search: bool,
draws_ranges: bool,
cli_command: Option<&'a str>,
}
struct ParameterRow<'a> {
model_id: &'a str,
index: usize,
descriptor: &'a ParamDescriptor,
panel_value: Option<&'a ParamValue>,
in_table: Option<bool>,
preview: Option<&'a LevelPreview>,
issue: Option<&'a DraftIssue>,
}
fn values_field_id(model_id: &str, param_id: &str) -> Id {
Id::new(("henad_sweep_values", model_id, param_id))
}
fn values_editor_id(model_id: &str, param_id: &str) -> Id {
Id::new(("henad_sweep_values_editor", model_id, param_id))
}
fn parameter_row(
ui: &mut egui::Ui,
rows: &mut FormRows,
context: RowContext<'_>,
row: &ParameterRow<'_>,
factor: &mut FactorDraft,
) {
let layout = rows.layout;
let descriptor = row.descriptor;
let baseline = row
.panel_value
.map(|value| display_value(descriptor, value))
.unwrap_or_default();
let vary_tooltip = match (context.search, layout.baseline_width.is_some()) {
(true, true) => {
"Search over this parameter. Unchecked parameters use values from the Parameters tab.".to_owned()
}
(true, false) => format!(
"Search over this parameter. Unchecked parameters use values from the Parameters tab. Current value: \
{baseline}."
),
(false, true) => "Vary this parameter. Unchecked parameters use values from the Parameters tab.".to_owned(),
(false, false) => format!(
"Vary this parameter. Unchecked parameters use values from the Parameters tab. Current value: {baseline}."
),
};
let add_label = |ui: &mut egui::Ui, factor: &mut FactorDraft| match row.in_table {
Some(true) => ui
.add(Label::new(format!("{MDI_TABLE_COLUMN} {}", descriptor.label)).truncate())
.on_hover_text("Set by design table"),
Some(false) => ui
.add(Label::new(descriptor.label).truncate())
.on_hover_text("Not in design table. Uses value from the Parameters tab."),
None => ui
.add(Checkbox::new(&mut factor.vary, descriptor.label))
.on_hover_text(vary_tooltip.as_str()),
};
let varied = row.in_table.is_none();
if layout.stacked {
let label = slot(ui, layout.cell_width, |ui| add_label(ui, factor));
ticked(ui, rows, context, row, factor, &label);
values_cell(ui, rows, context, row, factor, &label, layout.cell_width, &baseline);
return;
}
ui.with_layout(Layout::left_to_right(egui::Align::Min), |ui| {
let label = slot(ui, layout.label_width, |ui| add_label(ui, factor));
ticked(ui, rows, context, row, factor, &label);
if let Some(width) = layout.baseline_width {
slot(ui, width, |ui| {
let text = if varied && factor.vary {
RichText::new(&baseline).weak()
} else {
RichText::new(&baseline)
};
ui.add(Label::new(text).truncate())
.on_hover_text("Value from the Parameters tab, used for unchecked parameters");
});
}
values_cell(ui, rows, context, row, factor, &label, layout.values_width(), &baseline);
});
}
fn ticked(
ui: &egui::Ui,
rows: &mut FormRows,
context: RowContext<'_>,
row: &ParameterRow<'_>,
factor: &mut FactorDraft,
label: &egui::Response,
) {
if !(label.changed() && factor.vary) || LevelNoun::of(&row.descriptor.kind) == LevelNoun::Option {
return;
}
if context.search && factor.levels_text.trim().is_empty() {
factor.levels_text = whole_range_text(&row.descriptor.kind);
} else if !context.search {
rows.focus_later(ui, values_field_id(row.model_id, row.descriptor.id));
}
}
#[expect(clippy::too_many_arguments, reason = "the row's parts, each drawn in the cell")]
fn values_cell(
ui: &mut egui::Ui,
rows: &mut FormRows,
context: RowContext<'_>,
row: &ParameterRow<'_>,
factor: &mut FactorDraft,
label: &egui::Response,
width: f32,
baseline: &str,
) {
let layout = rows.layout;
column(ui, width, |ui| {
if row.in_table == Some(true) {
slot(ui, width, |ui| ui.weak("From design table"));
return;
}
if row.in_table.is_some() || !factor.vary {
if layout.baseline_width.is_none() {
slot(ui, width, |ui| ui.weak(format!("at {baseline}")));
}
return;
}
let noun = LevelNoun::of(&row.descriptor.kind);
let (control, text_len) = if noun == LevelNoun::Option {
let menu = options_menu(ui, &row.descriptor.kind, &mut factor.levels_text, width);
(menu.labelled_by(label.id), None)
} else {
let field = values_field(ui, context, row, &mut factor.levels_text, width);
rows.take_focus(ui, &field);
let len = factor.levels_text.chars().count();
(field.labelled_by(label.id), Some(len))
};
rows.reveal_row(ui, DraftSite::Factor(row.index), &control, text_len);
let held_back = text_len.is_some() && rows.holds_back_issue(ui, &control);
let note = row_note(
row.issue,
row.preview,
noun,
context.search,
context.draws_ranges,
held_back,
);
let tooltip = row.preview.filter(|_| row.issue.is_none()).and_then(preview_tooltip);
if let (Some(response), Some(tooltip)) = (note_line(ui, width, ¬e), tooltip) {
response.on_hover_text(tooltip);
}
});
}
fn values_field(
ui: &mut egui::Ui,
context: RowContext<'_>,
row: &ParameterRow<'_>,
text: &mut String,
width: f32,
) -> egui::Response {
let integer = matches!(row.descriptor.kind, ParamKind::U32 { .. });
let (hint, tooltip) = if context.search {
(whole_range_text(&row.descriptor.kind), SEARCH_LEVELS_TOOLTIP)
} else if context.draws_ranges {
("min:max or values".to_owned(), levels_tooltip(integer, true))
} else {
("Values or min:max:step".to_owned(), levels_tooltip(integer, false))
};
ui.horizontal(|ui| {
let field_width = (width - EDITOR_BUTTON_WIDTH - ui.spacing().item_spacing.x).max(0.0);
let field = ui
.add(
TextEdit::singleline(text)
.id(values_field_id(row.model_id, row.descriptor.id))
.hint_text(hint)
.desired_width(field_width),
)
.on_hover_text(tooltip);
let name = format!("Edit values of {}", row.descriptor.label);
let button = icon_button(ui, MDI_TUNE_VERTICAL, &name, "Edit values");
Popup::from_toggle_button_response(&button)
.id(values_editor_id(row.model_id, row.descriptor.id))
.close_behavior(PopupCloseBehavior::CloseOnClickOutside)
.width(EDITOR_WIDTH)
.show(|ui| {
let editor = ValuesEditor {
descriptor: row.descriptor,
panel_value: row.panel_value,
search: context.search,
draws_ranges: context.draws_ranges,
cli_command: context.cli_command,
note: row_note(
row.issue,
row.preview,
LevelNoun::Value,
context.search,
context.draws_ranges,
false,
),
};
values_editor(ui, &editor, text);
});
field
})
.inner
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditorSegment {
Values,
Range,
Drawn,
}
impl EditorSegment {
pub fn of(text: &str, draws_ranges: bool) -> Self {
match parse_levels(text) {
Ok(LevelSpec::Range { step: None, .. }) if draws_ranges => Self::Drawn,
Ok(LevelSpec::Range { .. }) => Self::Range,
_ => Self::Values,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RangeFields {
pub min: f64,
pub max: f64,
pub step: f64,
}
impl RangeFields {
pub fn of(text: &str, kind: &ParamKind) -> Self {
let (low, high) = kind_bounds(kind);
let default_step = |min: f64, max: f64| {
let step = round_step((max - min) / 4.0);
let step = if matches!(kind, ParamKind::U32 { .. }) {
step.round().max(1.0)
} else {
step
};
if step.is_finite() && step > 0.0 { step } else { 1.0 }
};
match parse_levels(text) {
Ok(LevelSpec::Range { min, max, step }) => Self {
min,
max,
step: step.unwrap_or_else(|| default_step(min, max)),
},
Ok(LevelSpec::Values(values)) => {
let numbers: Vec<f64> = values.iter().filter_map(|value| value.trim().parse().ok()).collect();
let min = numbers.iter().copied().fold(f64::INFINITY, f64::min);
let max = numbers.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if min.is_finite() && max.is_finite() && max > min {
Self {
min,
max,
step: default_step(min, max),
}
} else {
Self {
min: low,
max: high,
step: default_step(low, high),
}
}
}
_ => Self {
min: low,
max: high,
step: default_step(low, high),
},
}
}
pub fn text(self, kind: &ParamKind, drawn: bool) -> String {
let (min, max) = (number_text(kind, self.min), number_text(kind, self.max));
if drawn {
format!("{min}:{max}")
} else {
format!("{min}:{max}:{}", number_text(kind, self.step))
}
}
}
fn kind_bounds(kind: &ParamKind) -> (f64, f64) {
match *kind {
ParamKind::F32 { min, max, .. } => (f64::from(min), f64::from(max)),
ParamKind::U32 { min, max, .. } => (f64::from(min), f64::from(max)),
ParamKind::Bool { .. } | ParamKind::Choice { .. } => (0.0, 1.0),
}
}
pub fn bounds_text(kind: &ParamKind) -> String {
let (low, high) = kind_bounds(kind);
let text = format!("{} to {}", number_text(kind, low), number_text(kind, high));
match kind {
ParamKind::U32 { .. } => format!("{text}, integers"),
_ => text,
}
}
pub fn segment_text(segment: EditorSegment, text: &str, kind: &ParamKind) -> String {
let fields = RangeFields::of(text, kind);
match segment {
EditorSegment::Values => match parse_levels(text) {
Ok(LevelSpec::Values(_)) => text.to_owned(),
Ok(LevelSpec::Range { .. }) => {
format!("{},{}", number_text(kind, fields.min), number_text(kind, fields.max))
}
_ => String::new(),
},
EditorSegment::Range => fields.text(kind, false),
EditorSegment::Drawn => fields.text(kind, true),
}
}
pub fn whole_range_fill(kind: &ParamKind, draws_ranges: bool) -> String {
if draws_ranges {
whole_range_text(kind)
} else {
levels_example(kind)
}
}
pub fn both_ends_fill(kind: &ParamKind) -> String {
let (low, high) = kind_bounds(kind);
format!("{},{}", number_text(kind, low), number_text(kind, high))
}
pub fn around_fill(kind: &ParamKind, value: &ParamValue) -> String {
let center = match *value {
ParamValue::F32(number) => number.to_string().parse().unwrap_or(f64::from(number)),
ParamValue::U32(number) => f64::from(number),
ParamValue::Bool(_) | ParamValue::Choice(_) => return format_value(kind, value),
};
let (low, high) = kind_bounds(kind);
let mut texts: Vec<String> = Vec::new();
for factor in [0.5, 1.0, 1.5] {
let text = number_text(kind, (center * factor).clamp(low, high));
if !texts.contains(&text) {
texts.push(text);
}
}
texts.join(",")
}
struct ValuesEditor<'a> {
descriptor: &'a ParamDescriptor,
panel_value: Option<&'a ParamValue>,
search: bool,
draws_ranges: bool,
cli_command: Option<&'a str>,
note: Note,
}
fn values_editor(ui: &mut egui::Ui, editor: &ValuesEditor<'_>, text: &mut String) {
let kind = &editor.descriptor.kind;
ui.strong(editor.descriptor.label);
ui.weak(bounds_text(kind));
let mut segment = EditorSegment::of(text, editor.draws_ranges);
let drawn_label = if editor.search {
"Range to search"
} else {
"Range to draw from"
};
let choices = [
(EditorSegment::Values, "Values", "List values one by one"),
(EditorSegment::Range, "Range", "Step from a minimum to a maximum"),
(
EditorSegment::Drawn,
drawn_label,
"Allow any value from a minimum to a maximum",
),
];
let draws_ranges = editor.draws_ranges;
let switched = segmented(ui, &mut segment, &choices, |choice| {
if choice == EditorSegment::Drawn && !draws_ranges {
Err("Select Latin hypercube or Uniform random under Design to draw from a range")
} else {
Ok(())
}
});
if switched {
*text = segment_text(segment, text, kind);
}
match segment {
EditorSegment::Values => {
ui.weak("Enter comma-separated values, such as 0.1, 0.2.");
}
EditorSegment::Range | EditorSegment::Drawn => range_fields(ui, kind, segment, text),
}
ui.horizontal_wrapped(|ui| {
let whole = ui
.small_button("Whole range")
.on_hover_text("Step across the whole range");
if whole.clicked() {
*text = whole_range_fill(kind, editor.draws_ranges);
}
if ui
.small_button("Both ends")
.on_hover_text("Minimum and maximum only")
.clicked()
{
*text = both_ends_fill(kind);
}
if let Some(value) = editor.panel_value {
let around = ui
.small_button("Around current")
.on_hover_text("0.5×, 1× and 1.5× the Parameters tab value, within bounds");
if around.clicked() {
*text = around_fill(kind, value);
}
let current = ui
.small_button("Current value")
.on_hover_text("Parameters tab value only");
if current.clicked() {
*text = format_value(kind, value);
}
}
});
note_label(ui, &editor.note);
ui.weak(match editor.cli_command {
Some(command) => format!("Same format as {command} --vary"),
None => "Same format as --vary on the command line".to_owned(),
});
}
fn range_fields(ui: &mut egui::Ui, kind: &ParamKind, segment: EditorSegment, text: &mut String) {
let mut fields = RangeFields::of(text, kind);
let (low, high) = kind_bounds(kind);
let whole = matches!(kind, ParamKind::U32 { .. });
let speed = ((high - low) / 200.0).max(if whole { 1.0 } else { 0.001 });
fn number(value: &mut f64, range: std::ops::RangeInclusive<f64>, speed: f64, whole: bool) -> DragValue<'_> {
let drag = DragValue::new(value).range(range).speed(speed);
if whole { drag.fixed_decimals(0) } else { drag }
}
let mut changed = false;
egui::Grid::new(("henad_sweep_range_fields", segment == EditorSegment::Drawn))
.num_columns(2)
.show(ui, |ui| {
let label = ui.label("Minimum");
changed |= ui
.add(number(&mut fields.min, low..=high, speed, whole))
.labelled_by(label.id)
.changed();
ui.end_row();
let label = ui.label("Maximum");
changed |= ui
.add(number(&mut fields.max, low..=high, speed, whole))
.labelled_by(label.id)
.changed();
ui.end_row();
if segment == EditorSegment::Range {
let smallest = if whole { 1.0 } else { f64::from(f32::EPSILON) };
let label = ui.label("Step");
changed |= ui
.add(number(
&mut fields.step,
smallest..=(high - low).max(smallest),
speed,
whole,
))
.labelled_by(label.id)
.changed();
ui.end_row();
}
});
if changed {
*text = fields.text(kind, segment == EditorSegment::Drawn);
}
}
pub fn option_names(kind: &ParamKind) -> Vec<String> {
match kind {
ParamKind::Bool { .. } => [false, true]
.map(|flag| format_value(kind, &ParamValue::Bool(flag)))
.to_vec(),
ParamKind::Choice { options, .. } => (0..options.len())
.map(|index| format_value(kind, &ParamValue::Choice(index)))
.collect(),
ParamKind::F32 { .. } | ParamKind::U32 { .. } => Vec::new(),
}
}
pub fn picked_options(kind: &ParamKind, text: &str) -> Vec<bool> {
let names = option_names(kind);
if text.trim() == "all" {
return vec![true; names.len()];
}
let mut picked = vec![false; names.len()];
for part in text.split(',') {
let index = match parse_value(kind, part.trim()) {
Ok(ParamValue::Bool(flag)) => usize::from(flag),
Ok(ParamValue::Choice(index)) => index,
_ => continue,
};
if let Some(pick) = picked.get_mut(index) {
*pick = true;
}
}
picked
}
pub fn options_text(names: &[String], picked: &[bool]) -> String {
if !picked.is_empty() && picked.iter().all(|pick| *pick) {
return "all".to_owned();
}
chosen_options(names, picked).join(",")
}
fn chosen_options<'a>(names: &'a [String], picked: &[bool]) -> Vec<&'a str> {
names
.iter()
.zip(picked)
.filter(|(_, pick)| **pick)
.map(|(name, _)| name.as_str())
.collect()
}
pub fn options_button_text(names: &[String], picked: &[bool]) -> String {
match options_text(names, picked).as_str() {
"all" => "All options".to_owned(),
"" => "None".to_owned(),
_ => chosen_options(names, picked).join(", "),
}
}
fn options_menu(ui: &mut egui::Ui, kind: &ParamKind, text: &mut String, width: f32) -> egui::Response {
let names = option_names(kind);
let mut picked = picked_options(kind, text);
let button = Button::new(options_button_text(&names, &picked))
.truncate()
.min_size(vec2(width, 0.0));
let mut changed = false;
let (response, _) = ui
.scope(|ui| {
ui.set_max_width(width);
MenuButton::from_button(button)
.config(MenuConfig::new().close_behavior(PopupCloseBehavior::CloseOnClickOutside))
.ui(ui, |ui| {
let mut all = picked.iter().all(|pick| *pick);
if ui.checkbox(&mut all, "All options").changed() {
picked.fill(all);
changed = true;
}
for (name, pick) in names.iter().zip(&mut picked) {
changed |= ui.checkbox(pick, name.as_str()).changed();
}
})
})
.inner;
if changed {
*text = options_text(&names, &picked);
}
response.on_hover_text("Select options to run")
}
#[cfg(test)]
mod tests {
use henad_core::explore::design::DesignKind;
use henad_core::explore::factor::{FactorSpec, LevelSpec};
use henad_core::params::{ParamApply, ParamDescriptor, ParamFormat, ParamKind, ParamValue};
use super::{
EditorSegment, LevelNoun, RangeFields, around_fill, both_ends_fill, bounds_text, format_hint,
options_button_text, options_text, picked_options, preview_note, preview_tooltip, row_note, segment_text,
values_editor_id, values_field_id, whole_range_fill,
};
use crate::ui::sweep::draft::{DraftIssue, DraftSite, IssueKind, LevelPreview, levels_text};
use crate::ui::sweep::layout::Note;
const RATE: ParamKind = ParamKind::F32 {
min: 0.0,
max: 1.0,
default: 0.3,
step: None,
};
const WIDTH: ParamKind = ParamKind::U32 {
min: 1,
max: 16384,
default: 1024,
};
const FIXED: ParamKind = ParamKind::U32 {
min: 5,
max: 5,
default: 5,
};
const NETWORK: ParamKind = ParamKind::Choice {
options: &["Random", "Geometric"],
default: 0,
};
const LAYOUT: ParamKind = ParamKind::Choice {
options: &["Random", "Geometric", "Lattice"],
default: 0,
};
fn listed(values: &[&str]) -> LevelPreview {
LevelPreview::Listed {
count: values.len(),
values: values.iter().map(|&value| value.to_owned()).collect(),
last: values.last().map_or_else(String::new, |&value| value.to_owned()),
}
}
#[test]
fn a_note_lists_up_to_six_values_and_skips_to_the_last_past_them() {
let five = listed(&["0.1", "0.2", "0.3", "0.4", "0.5"]);
assert_eq!(
preview_note(&five, LevelNoun::Value, false),
"5 values: 0.1, 0.2, 0.3, 0.4, 0.5"
);
assert_eq!(preview_tooltip(&five), None, "every value shows already");
assert_eq!(preview_note(&listed(&["0.3"]), LevelNoun::Value, false), "1 value: 0.3");
let twelve: Vec<String> = (1..=12).map(|tenth| format!("{}", f64::from(tenth) / 10.0)).collect();
let twelve: Vec<&str> = twelve.iter().map(String::as_str).collect();
let twelve = listed(&twelve);
assert_eq!(
preview_note(&twelve, LevelNoun::Value, false),
"12 values: 0.1, 0.2, 0.3, …, 1.2"
);
assert_eq!(
preview_tooltip(&twelve).as_deref(),
Some("0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2")
);
let many = LevelPreview::Listed {
count: 60,
values: (0..50).map(|value| value.to_string()).collect(),
last: "59".to_owned(),
};
assert!(
preview_tooltip(&many).is_some_and(|text| text.ends_with("49 and 10 more")),
"the hover text stops at 50"
);
}
#[test]
fn a_note_names_options_ticks_and_ranges_in_the_mode_s_words() {
let options = listed(&["Random", "Geometric"]);
assert_eq!(
preview_note(&options, LevelNoun::Option, false),
"2 options: Random, Geometric"
);
assert_eq!(
preview_note(&options, LevelNoun::Option, true),
"2 options: Random, Geometric"
);
assert_eq!(
preview_note(&listed(&["100", "200", "300"]), LevelNoun::Tick, false),
"3 ticks: 100, 200, 300"
);
assert_eq!(
preview_note(&listed(&["0.1", "0.2"]), LevelNoun::Value, true),
"2 values to select from: 0.1, 0.2"
);
let range = LevelPreview::Drawn {
min: "0.1".to_owned(),
max: "0.5".to_owned(),
};
assert_eq!(
preview_note(&range, LevelNoun::Value, false),
"Any value from 0.1 to 0.5, drawn by the design"
);
assert_eq!(
preview_note(&range, LevelNoun::Value, true),
"Any value from 0.1 to 0.5"
);
let ticks = LevelPreview::Drawn {
min: "0".to_owned(),
max: "500".to_owned(),
};
assert_eq!(preview_note(&ticks, LevelNoun::Tick, true), "Any tick from 0 to 500");
}
#[test]
fn a_field_being_typed_in_shows_the_format_until_its_issue_is_due() {
let invalid = DraftIssue::new(DraftSite::Factor(2), "'0.x' is not a number");
let preview = listed(&["0.1"]);
assert_eq!(
row_note(Some(&invalid), None, LevelNoun::Value, false, false, true),
Note::Weak("Format: 0.1, 0.2 or min:max:step".to_owned())
);
assert_eq!(
row_note(Some(&invalid), None, LevelNoun::Value, false, false, false),
Note::Issue(IssueKind::Invalid, "'0.x' is not a number".to_owned())
);
let missing = DraftIssue::missing(DraftSite::Factor(2), "Enter values, such as 0:1:0.2");
assert_eq!(
row_note(Some(&missing), None, LevelNoun::Value, false, false, true),
Note::Issue(IssueKind::Missing, "Enter values, such as 0:1:0.2".to_owned()),
"missing input is never held back"
);
assert_eq!(
row_note(None, Some(&preview), LevelNoun::Value, false, false, false),
Note::Weak("1 value: 0.1".to_owned())
);
assert_eq!(format_hint(LevelNoun::Tick, true), "Format: min:max or 100, 200");
}
#[test]
fn the_editor_reads_the_form_of_a_row_s_text() {
assert_eq!(EditorSegment::of("0.1, 0.2", false), EditorSegment::Values);
assert_eq!(EditorSegment::of("", false), EditorSegment::Values);
assert_eq!(EditorSegment::of("0:1:0.2", true), EditorSegment::Range);
assert_eq!(EditorSegment::of("0:1", true), EditorSegment::Drawn);
assert_eq!(
EditorSegment::of("1:10", false),
EditorSegment::Range,
"a range with no step steps where nothing draws"
);
}
#[test]
fn the_editor_switches_text_between_its_forms() {
assert_eq!(segment_text(EditorSegment::Range, "0.1, 0.5", &RATE), "0.1:0.5:0.1");
assert_eq!(segment_text(EditorSegment::Drawn, "0.1:0.5:0.1", &RATE), "0.1:0.5");
assert_eq!(segment_text(EditorSegment::Values, "0.1:0.5:0.1", &RATE), "0.1,0.5");
assert_eq!(segment_text(EditorSegment::Range, "", &WIDTH), "1:16384:5000");
assert_eq!(
RangeFields {
min: 0.1,
max: 0.9,
step: 0.2
}
.text(&RATE, false),
"0.1:0.9:0.2"
);
assert_eq!(bounds_text(&RATE), "0 to 1");
assert_eq!(bounds_text(&WIDTH), "1 to 16384, integers");
}
fn vary_takes(kind: &ParamKind, text: &str) -> bool {
let descriptor = ParamDescriptor {
id: "param",
label: "Param",
kind: kind.clone(),
apply: ParamApply::Live,
format: ParamFormat::Plain,
};
LevelSpec::parse(text).is_ok_and(|levels| {
FactorSpec::param("param", levels)
.resolve(&[descriptor], &[], &DesignKind::Factorial)
.is_ok()
})
}
#[test]
fn quick_fills_write_the_text_a_command_line_takes() {
assert_eq!(whole_range_fill(&RATE, false), "0:1:0.2");
assert_eq!(whole_range_fill(&RATE, true), "0:1");
assert_eq!(both_ends_fill(&WIDTH), "1,16384");
assert_eq!(around_fill(&RATE, &ParamValue::F32(0.3)), "0.15,0.3,0.45");
assert_eq!(
around_fill(&RATE, &ParamValue::F32(0.8)),
"0.4,0.8,1",
"held within the bounds"
);
assert_eq!(
around_fill(&WIDTH, &ParamValue::U32(1)),
"1,2",
"a whole number rounds, and a repeat is dropped"
);
assert_eq!(
whole_range_fill(&FIXED, false),
"5,5",
"a range with no width lists both ends"
);
let loaded = LevelSpec::Values(vec!["1".to_owned(), "16384".to_owned()]);
assert_eq!(levels_text(&loaded), "1,16384", "a row loaded from a spec");
let fills = [
(&RATE, whole_range_fill(&RATE, false)),
(&RATE, both_ends_fill(&RATE)),
(&RATE, around_fill(&RATE, &ParamValue::F32(0.3))),
(&RATE, segment_text(EditorSegment::Values, "0.1:0.5:0.1", &RATE)),
(&WIDTH, whole_range_fill(&WIDTH, false)),
(&WIDTH, both_ends_fill(&WIDTH)),
(&WIDTH, around_fill(&WIDTH, &ParamValue::U32(1024))),
(&WIDTH, levels_text(&loaded)),
(&FIXED, whole_range_fill(&FIXED, false)),
(
&LAYOUT,
options_text(&super::option_names(&LAYOUT), &[true, true, true]),
),
(
&LAYOUT,
options_text(&super::option_names(&LAYOUT), &[true, false, true]),
),
];
for (kind, text) in fills {
assert!(vary_takes(kind, &text), "--vary refuses {text:?}");
}
}
#[test]
fn a_values_field_belongs_to_one_parameter_of_one_model() {
let field = values_field_id("sir", "infection_rate");
assert_eq!(field, values_field_id("sir", "infection_rate"));
assert_ne!(field, values_field_id("sir", "recovery_rate"));
assert_ne!(
field,
values_field_id("virus_network", "infection_rate"),
"another model's row keeps its own undo history"
);
assert_ne!(
values_editor_id("sir", "infection_rate"),
values_editor_id("virus_network", "infection_rate")
);
}
#[test]
fn the_options_menu_reads_and_writes_all_or_a_list() {
let names = super::option_names(&NETWORK);
assert_eq!(names, ["Random", "Geometric"]);
assert_eq!(picked_options(&NETWORK, "all"), [true, true]);
assert_eq!(picked_options(&NETWORK, "Geometric"), [false, true]);
assert_eq!(picked_options(&NETWORK, "Random, Geometric"), [true, true]);
assert_eq!(picked_options(&NETWORK, "Random,Geometric"), [true, true]);
assert_eq!(picked_options(&NETWORK, "Square"), [false, false]);
assert_eq!(options_text(&names, &[true, true]), "all");
assert_eq!(options_text(&names, &[false, true]), "Geometric");
assert_eq!(options_text(&names, &[false, false]), "");
assert_eq!(options_button_text(&names, &[true, true]), "All options");
assert_eq!(options_button_text(&names, &[false, false]), "None");
let three = super::option_names(&LAYOUT);
assert_eq!(options_text(&three, &[true, false, true]), "Random,Lattice");
assert_eq!(
options_button_text(&three, &[true, false, true]),
"Random, Lattice",
"the button reads as prose"
);
let flag = ParamKind::Bool { default: false };
assert_eq!(picked_options(&flag, "true"), [false, true]);
assert_eq!(options_text(&super::option_names(&flag), &[true, false]), "false");
}
}