369 lines
17 KiB
Rust
369 lines
17 KiB
Rust
//! Structured configuration rule editing and non-mutating row preview.
|
|
use crate::SolveApp;
|
|
use egui::{RichText, Ui};
|
|
use serde_json::{Value, json};
|
|
use solvecraft_engine::doc::config::{Column, ConfigRule, RuleCondition, RuleEffect, RuleOperator};
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Editor {
|
|
rule: ConfigRule,
|
|
row: String,
|
|
preview: Option<Value>,
|
|
|
|
preview_revision: u64,
|
|
pub(super) drafts: std::collections::BTreeMap<(String, String), String>,
|
|
pub(super) table: Option<(u64, Value)>,
|
|
}
|
|
impl Default for Editor {
|
|
fn default() -> Self {
|
|
Self {
|
|
rule: ConfigRule {
|
|
id: String::new(),
|
|
name: "New rule".into(),
|
|
enabled: true,
|
|
length_unit: None,
|
|
condition: RuleCondition::Always,
|
|
effect: RuleEffect::DriveParameter { parameter: String::new(), expression: "10 mm".into() },
|
|
},
|
|
row: String::new(),
|
|
preview: None,
|
|
|
|
preview_revision: 0,
|
|
drafts: Default::default(),
|
|
table: None,
|
|
}
|
|
}
|
|
}
|
|
fn choose(ui: &mut Ui, id: &str, value: &mut String, options: &[String]) {
|
|
egui::ComboBox::from_id_salt(id).selected_text(if value.is_empty() { "Choose…" } else { value.as_str() }).width(130.0).show_ui(ui, |ui| {
|
|
for option in options {
|
|
ui.selectable_value(value, option.clone(), option);
|
|
}
|
|
});
|
|
}
|
|
fn expression(ui: &mut Ui, label: &str, value: &mut String) {
|
|
ui.label(label);
|
|
ui.add(egui::TextEdit::singleline(value).desired_width(160.0));
|
|
}
|
|
|
|
pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option<String>, editor: &mut Editor) {
|
|
if editor.preview_revision != app.session.revision {
|
|
editor.preview = None;
|
|
}
|
|
let rules = app.session.doc.configs.rules.clone();
|
|
let parameters: Vec<String> =
|
|
app.session.doc.configs.columns.iter().filter_map(|c| if let Column::Param(n) = c { Some(n.clone()) } else { None }).collect();
|
|
let all_parameters: Vec<String> = app.session.doc.all_param_exprs().into_iter().map(|p| p.0).collect();
|
|
let features: Vec<(u64, String)> = app.session.doc.features.iter().map(|f| (f.id, f.name.clone())).collect();
|
|
let suppression_features: Vec<(u64, String)> =
|
|
features.iter().filter(|(id, _)| app.session.doc.configs.columns.contains(&Column::Suppress(*id))).cloned().collect();
|
|
ui.separator();
|
|
ui.label(RichText::new("Configuration rules").strong());
|
|
ui.add(egui::Label::new("Rules run from top to bottom. Matched drives change effective values.").wrap());
|
|
ui.add(egui::Label::new("Matched restrictions check the final values.").wrap());
|
|
for (index, rule) in rules.iter().enumerate() {
|
|
ui.push_id(&rule.id, |ui| {
|
|
ui.horizontal(|ui| {
|
|
let mut enabled = rule.enabled;
|
|
if ui.checkbox(&mut enabled, "").on_hover_text("Enable rule").changed() {
|
|
let mut r = rule.clone();
|
|
r.enabled = enabled;
|
|
*error = app.run("config.rule", json!({"rule":r})).err();
|
|
}
|
|
ui.add(egui::Label::new(&rule.name).truncate());
|
|
if ui.small_button("Edit").clicked() {
|
|
editor.rule = rule.clone();
|
|
}
|
|
if ui.add_enabled(index > 0, egui::Button::new("↑").small()).on_hover_text("Move earlier").clicked() {
|
|
*error = app.run("config.rule", json!({"id":rule.id,"index":index-1})).err();
|
|
}
|
|
if ui.add_enabled(index + 1 < rules.len(), egui::Button::new("↓").small()).on_hover_text("Move later").clicked() {
|
|
*error = app.run("config.rule", json!({"id":rule.id,"index":index+1})).err();
|
|
}
|
|
if ui.small_button("Delete").clicked() {
|
|
*error = app.run("config.rule", json!({"id":rule.id,"delete":true})).err();
|
|
}
|
|
});
|
|
ui.label(match &rule.effect {
|
|
RuleEffect::DriveParameter { parameter, expression } => format!("Drives {parameter} = {expression}"),
|
|
RuleEffect::DriveSuppression { feature, value } => format!(
|
|
"Drives suppression {} = {value}",
|
|
features.iter().find(|f| f.0 == *feature).map(|f| f.1.as_str()).unwrap_or("missing feature")
|
|
),
|
|
RuleEffect::ParameterRange { parameter, minimum, maximum } => format!("Restricts {parameter}: {minimum} through {maximum}"),
|
|
RuleEffect::AllowParameterValues { parameter, expressions } => format!("Restricts {parameter}: {}", expressions.join(", ")),
|
|
});
|
|
});
|
|
}
|
|
ui.collapsing("Add or edit rule", |ui| {
|
|
ui.horizontal(|ui| {
|
|
expression(ui, "Rule name", &mut editor.rule.name);
|
|
ui.checkbox(&mut editor.rule.enabled, "Enabled");
|
|
if ui.small_button("New").clicked() {
|
|
editor.rule = Editor::default().rule;
|
|
}
|
|
});
|
|
let mut length_unit = editor.rule.length_unit.clone().unwrap_or_else(|| app.session.doc.units.clone());
|
|
let old_unit = length_unit.clone();
|
|
ui.horizontal(|ui| {
|
|
ui.label("Length units");
|
|
choose(ui, "rule_length_units", &mut length_unit, &["mm".into(), "cm".into(), "m".into(), "in".into(), "ft".into()]);
|
|
});
|
|
if length_unit != old_unit {
|
|
editor.rule.length_unit = Some(length_unit);
|
|
}
|
|
condition_editor(ui, &mut editor.rule.condition, &all_parameters, &features, 1);
|
|
let mut effect_kind = match editor.rule.effect {
|
|
RuleEffect::DriveParameter { .. } => "Drive parameter",
|
|
RuleEffect::DriveSuppression { .. } => "Drive suppression",
|
|
RuleEffect::ParameterRange { .. } => "Range",
|
|
RuleEffect::AllowParameterValues { .. } => "Allowed values",
|
|
}
|
|
.to_string();
|
|
let prior = effect_kind.clone();
|
|
ui.horizontal(|ui| {
|
|
ui.label("Then");
|
|
choose(
|
|
ui,
|
|
"rule_effect_kind",
|
|
&mut effect_kind,
|
|
&["Drive parameter".into(), "Drive suppression".into(), "Range".into(), "Allowed values".into()],
|
|
);
|
|
});
|
|
if prior != effect_kind {
|
|
let parameter = parameters.first().cloned().unwrap_or_default();
|
|
editor.rule.effect = match effect_kind.as_str() {
|
|
"Drive suppression" => RuleEffect::DriveSuppression { feature: suppression_features.first().map(|f| f.0).unwrap_or(0), value: true },
|
|
"Range" => RuleEffect::ParameterRange { parameter, minimum: "0 mm".into(), maximum: "100 mm".into() },
|
|
"Allowed values" => RuleEffect::AllowParameterValues { parameter, expressions: vec!["10 mm".into(), "20 mm".into()] },
|
|
_ => RuleEffect::DriveParameter { parameter, expression: "10 mm".into() },
|
|
};
|
|
}
|
|
match &mut editor.rule.effect {
|
|
RuleEffect::DriveParameter { parameter, expression: value } => {
|
|
ui.horizontal(|ui| {
|
|
choose(ui, "effect_parameter", parameter, ¶meters);
|
|
expression(ui, "Value", value);
|
|
});
|
|
}
|
|
RuleEffect::DriveSuppression { feature, value } => {
|
|
ui.horizontal(|ui| {
|
|
feature_choice(ui, "effect_feature", feature, &suppression_features);
|
|
ui.checkbox(value, "Suppressed");
|
|
});
|
|
}
|
|
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
|
choose(ui, "range_parameter", parameter, ¶meters);
|
|
ui.horizontal(|ui| {
|
|
expression(ui, "Minimum", minimum);
|
|
});
|
|
ui.horizontal(|ui| {
|
|
expression(ui, "Maximum", maximum);
|
|
});
|
|
}
|
|
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
|
choose(ui, "allowed_parameter", parameter, ¶meters);
|
|
let mut text = expressions.join("; ");
|
|
ui.label("Allowed expressions, separated by semicolons");
|
|
if ui.add(egui::TextEdit::singleline(&mut text).desired_width(300.0)).changed() {
|
|
*expressions = text.split(';').map(|s| s.trim().to_string()).collect();
|
|
}
|
|
}
|
|
}
|
|
if ui.add_enabled(!app.session.doc.configs.rows.is_empty(), egui::Button::new("Save Rule")).clicked() {
|
|
let result = (|| -> Result<(), String> {
|
|
let mut rule = editor.rule.clone();
|
|
if rule.id.is_empty() {
|
|
rule.id = (1..).map(|n| format!("Rule{n}")).find(|id| !rules.iter().any(|r| r.id == *id)).unwrap_or_default();
|
|
}
|
|
app.run("config.rule", json!({"rule":rule.clone()}))?;
|
|
editor.rule = app.session.doc.configs.rules.iter().find(|existing| existing.id == rule.id).cloned().unwrap_or(rule);
|
|
Ok(())
|
|
})();
|
|
*error = result.err();
|
|
}
|
|
});
|
|
let rows: Vec<String> = app.session.doc.configs.rows.iter().map(|r| r.name.clone()).collect();
|
|
if !rows.contains(&editor.row) {
|
|
editor.row = app.session.doc.configs.active.clone().or_else(|| rows.first().cloned()).unwrap_or_default();
|
|
}
|
|
let previous_row = editor.row.clone();
|
|
ui.horizontal(|ui| {
|
|
choose(ui, "preview_configuration", &mut editor.row, &rows);
|
|
if ui.add_enabled(!editor.row.is_empty(), egui::Button::new("Preview Row")).clicked() {
|
|
match app.session.execute("config.preview", &json!({"row":editor.row})) {
|
|
Ok(result) => {
|
|
editor.preview = Some(result);
|
|
editor.preview_revision = app.session.revision;
|
|
*error = None;
|
|
}
|
|
Err(e) => {
|
|
editor.preview = None;
|
|
*error = Some(e.to_string());
|
|
}
|
|
}
|
|
}
|
|
});
|
|
if previous_row != editor.row {
|
|
editor.preview = None;
|
|
}
|
|
if let Some(preview) = &editor.preview {
|
|
ui.collapsing(format!("Preview: {}", preview["active"].as_str().unwrap_or("row")), |ui| {
|
|
ui.label("Preview leaves the current design unchanged.");
|
|
if let Some(styles) = preview["styles"].as_array() {
|
|
for style in styles {
|
|
ui.add(
|
|
egui::Label::new(format!(
|
|
"{}: {} (shared definition)",
|
|
style["target"].as_str().unwrap_or("Appearance"),
|
|
if style["dormant"].as_bool() == Some(true) { "dormant" } else { "resolved" }
|
|
))
|
|
.wrap(),
|
|
);
|
|
}
|
|
}
|
|
if let Some(trace) = preview["trace"].as_array() {
|
|
for entry in trace {
|
|
ui.label(format!(
|
|
"{} — {}",
|
|
entry["name"].as_str().unwrap_or("rule"),
|
|
if entry["matched"].as_bool() == Some(true) { "matched" } else { "skipped" }
|
|
));
|
|
if entry["matched"].as_bool() == Some(true)
|
|
&& let Ok(effect) = serde_json::from_value::<RuleEffect>(entry["effect"].clone())
|
|
{
|
|
ui.label(match effect {
|
|
RuleEffect::DriveParameter { parameter, expression } => format!("Driven: {parameter} = {expression}"),
|
|
RuleEffect::DriveSuppression { feature, value } => format!(
|
|
"Driven: {} is {}",
|
|
features.iter().find(|f| f.0 == feature).map(|f| f.1.as_str()).unwrap_or("feature"),
|
|
if value { "suppressed" } else { "unsuppressed" }
|
|
),
|
|
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
|
format!("Restricted: {parameter}, {minimum} through {maximum}")
|
|
}
|
|
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
|
format!("Restricted: {parameter}, {}", expressions.join("; "))
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if let Some(parameters) = preview["parameters"].as_array() {
|
|
for parameter in parameters {
|
|
if let Some(name) = parameter["name"].as_str()
|
|
&& all_parameters.iter().any(|p| p == name)
|
|
{
|
|
ui.label(format!("{name} = {}", parameter["expression"].as_str().unwrap_or("")));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
fn feature_choice(ui: &mut Ui, id: &str, feature: &mut u64, features: &[(u64, String)]) {
|
|
egui::ComboBox::from_id_salt(id)
|
|
.selected_text(features.iter().find(|f| f.0 == *feature).map(|f| f.1.as_str()).unwrap_or("Choose feature…"))
|
|
.show_ui(ui, |ui| {
|
|
for (id, name) in features {
|
|
ui.selectable_value(feature, *id, name);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn condition_editor(ui: &mut Ui, condition: &mut RuleCondition, parameters: &[String], features: &[(u64, String)], depth: usize) {
|
|
let mut kind = match condition {
|
|
RuleCondition::Always => "Always",
|
|
RuleCondition::Parameter { .. } => "Parameter",
|
|
RuleCondition::Suppression { .. } => "Suppression",
|
|
RuleCondition::All { .. } => "All",
|
|
RuleCondition::Any { .. } => "Any",
|
|
RuleCondition::Not { .. } => "Not",
|
|
}
|
|
.to_string();
|
|
let previous = kind.clone();
|
|
let mut choices = vec!["Always".into(), "Parameter".into(), "Suppression".into()];
|
|
if depth < 8 {
|
|
choices.extend(["All".into(), "Any".into(), "Not".into()]);
|
|
}
|
|
ui.horizontal(|ui| {
|
|
ui.label("When");
|
|
choose(ui, "condition_kind", &mut kind, &choices);
|
|
});
|
|
if previous != kind {
|
|
*condition = match kind.as_str() {
|
|
"Parameter" => RuleCondition::Parameter {
|
|
parameter: parameters.first().cloned().unwrap_or_default(),
|
|
operator: RuleOperator::Ge,
|
|
expression: "10 mm".into(),
|
|
},
|
|
"Suppression" => RuleCondition::Suppression { feature: features.first().map(|f| f.0).unwrap_or(0), value: true },
|
|
"All" => RuleCondition::All { conditions: vec![RuleCondition::Always] },
|
|
"Any" => RuleCondition::Any { conditions: vec![RuleCondition::Always] },
|
|
"Not" => RuleCondition::Not { condition: Box::new(RuleCondition::Always) },
|
|
_ => RuleCondition::Always,
|
|
};
|
|
}
|
|
match condition {
|
|
RuleCondition::Parameter { parameter, operator, expression } => {
|
|
ui.horizontal(|ui| {
|
|
choose(ui, "condition_parameter", parameter, parameters);
|
|
egui::ComboBox::from_id_salt("condition_operator")
|
|
.width(45.0)
|
|
.selected_text(match operator {
|
|
RuleOperator::Eq => "=",
|
|
RuleOperator::Ne => "≠",
|
|
RuleOperator::Lt => "<",
|
|
RuleOperator::Le => "≤",
|
|
RuleOperator::Gt => ">",
|
|
RuleOperator::Ge => "≥",
|
|
})
|
|
.show_ui(ui, |ui| {
|
|
for (label, op) in [
|
|
("=", RuleOperator::Eq),
|
|
("≠", RuleOperator::Ne),
|
|
("<", RuleOperator::Lt),
|
|
("≤", RuleOperator::Le),
|
|
(">", RuleOperator::Gt),
|
|
("≥", RuleOperator::Ge),
|
|
] {
|
|
ui.selectable_value(operator, op, label);
|
|
}
|
|
});
|
|
ui.add(egui::TextEdit::singleline(expression).desired_width(110.0));
|
|
});
|
|
}
|
|
RuleCondition::Suppression { feature, value } => {
|
|
ui.horizontal(|ui| {
|
|
feature_choice(ui, "condition_feature", feature, features);
|
|
ui.checkbox(value, "Suppressed");
|
|
});
|
|
}
|
|
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
|
|
let mut remove = None;
|
|
for (index, child) in conditions.iter_mut().enumerate() {
|
|
ui.push_id(index, |ui| {
|
|
ui.indent("condition_group", |ui| {
|
|
condition_editor(ui, child, parameters, features, depth + 1);
|
|
if ui.small_button("Remove condition").clicked() {
|
|
remove = Some(index);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
if let Some(index) = remove {
|
|
conditions.remove(index);
|
|
}
|
|
if ui.add_enabled(conditions.len() < 32, egui::Button::new("Add condition").small()).clicked() {
|
|
conditions.push(RuleCondition::Always);
|
|
}
|
|
}
|
|
RuleCondition::Not { condition } => {
|
|
ui.indent("negated_condition", |ui| {
|
|
condition_editor(ui, condition, parameters, features, depth + 1);
|
|
});
|
|
}
|
|
RuleCondition::Always => {}
|
|
}
|
|
}
|