forked from 1b-agent/opencadstudio-upstream
Formats the branch the way main's 9cf9ff2 did, so merging main only
conflicts where the code itself differs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
431 lines
16 KiB
Rust
431 lines
16 KiB
Rust
//! Named-parameter table editor using buffered `(name, formula)` rows.
|
||
//! edited in a working buffer (`OpenCADStudio::named_parameter_editor_rows`)
|
||
//! and committed to `Scene::named_parameters` only on Apply — matching the
|
||
//! alias editor's "closing discards unapplied edits" convention, which
|
||
//! suits a formula table even better: `ParameterTable::set` validates (and
|
||
//! can reject) a formula immediately, so typing a not-yet-finished name or
|
||
//! formula must not touch the live table on every keystroke.
|
||
//!
|
||
//! Unlike the alias editor, each row also shows a live-computed resolved
|
||
//! value or error (`preview`, below) — built fresh from the whole buffer on
|
||
//! every render, not cached: the same "cheap enough to recompute from
|
||
//! scratch" philosophy `ParameterTable`/`parametric_solve.rs` already use
|
||
//! throughout this project, and it means the preview reflects circular/
|
||
//! undefined-reference problems across rows immediately, before Apply.
|
||
|
||
use crate::app::Message;
|
||
use crate::scene::named_parameters::ParameterTable;
|
||
use crate::scene::Scene;
|
||
use crate::t;
|
||
use crate::ui::style::common::muted_style;
|
||
use codec::types::Handle;
|
||
use codec::EntityType;
|
||
use iced::widget::tooltip::Position as TipPos;
|
||
use iced::widget::{button, column, container, row, scrollable, text, text_input, tooltip, Space};
|
||
use iced::{Background, Element, Length, Theme};
|
||
|
||
/// Short "Kind handle" label for an entity without a user-facing name.
|
||
fn entity_label(scene: &Scene, handle: Handle) -> String {
|
||
match scene.document.get_entity(handle) {
|
||
Some(EntityType::Line(_)) => format!("Line {handle}"),
|
||
Some(EntityType::Circle(_)) => format!("Circle {handle}"),
|
||
Some(EntityType::Arc(_)) => format!("Arc {handle}"),
|
||
Some(_) => format!("Entity {handle}"),
|
||
None => format!("(erased {handle})"),
|
||
}
|
||
}
|
||
|
||
/// One line per constraint currently driven by `name`, e.g.
|
||
/// `"Distance: Line 0x2A, Line 0x2B"` — empty when nothing references it
|
||
/// yet (a parameter defined but not yet used by any constraint).
|
||
fn usage_lines(scene: &Scene, name: &str) -> Vec<String> {
|
||
scene
|
||
.parameter_usage(name)
|
||
.iter()
|
||
.map(|u| {
|
||
let entities = u
|
||
.entities
|
||
.iter()
|
||
.map(|h| entity_label(scene, *h))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
format!("{:?}: {entities}", u.kind)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The "Used by" column's cell for one row: a compact kind-count summary
|
||
/// (e.g. `"Distance ×2, Radius ×1"`), with the full per-constraint entity
|
||
/// list (`usage_lines`) as a hover tooltip — the column is too narrow to
|
||
/// show entity lists inline once a parameter drives more than one or two
|
||
/// constraints. A blank name (a fresh, not-yet-named row) or a name driving
|
||
/// nothing shows a muted em dash instead.
|
||
fn used_by_cell<'a>(scene: &Scene, name: &str) -> Element<'a, Message> {
|
||
if name.is_empty() {
|
||
return Space::new().into();
|
||
}
|
||
let lines = usage_lines(scene, name);
|
||
if lines.is_empty() {
|
||
return text("—").size(11).style(muted_style).into();
|
||
}
|
||
let mut counts: Vec<(String, usize)> = Vec::new();
|
||
for line in &lines {
|
||
let kind = line.split(':').next().unwrap_or(line).to_string();
|
||
match counts.iter_mut().find(|(k, _)| *k == kind) {
|
||
Some((_, n)) => *n += 1,
|
||
None => counts.push((kind, 1)),
|
||
}
|
||
}
|
||
let summary = counts
|
||
.iter()
|
||
.map(|(k, n)| format!("{k} ×{n}"))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
let mut body = column![].spacing(2);
|
||
for line in &lines {
|
||
body = body.push(text(line.clone()).size(11));
|
||
}
|
||
tooltip(
|
||
text(summary).size(11).style(muted_style),
|
||
container(body)
|
||
.style(container::bordered_box)
|
||
.padding([4, 8]),
|
||
TipPos::Top,
|
||
)
|
||
.into()
|
||
}
|
||
|
||
/// Which column of a parameter row a text edit targets.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
pub enum ParamField {
|
||
Name,
|
||
Formula,
|
||
}
|
||
|
||
/// One working-buffer row. Free text until Apply — `ParameterTable::set`
|
||
/// does the real validation then, not on every keystroke.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct ParamEditorRow {
|
||
pub name: String,
|
||
pub formula: String,
|
||
}
|
||
|
||
/// Right-hand lane reserved for the scrollbar so it never overlaps the ✕
|
||
/// column — same convention and value as `alias_editor::GUTTER`.
|
||
const GUTTER: f32 = 16.0;
|
||
|
||
fn danger_style(theme: &Theme) -> iced::widget::text::Style {
|
||
iced::widget::text::Style {
|
||
color: Some(theme.palette().danger.base.color),
|
||
}
|
||
}
|
||
|
||
/// Every buffer index sharing a (trimmed) name with at least one other
|
||
/// non-blank row — `ParameterTable::set` upserts by name, so two rows typed
|
||
/// with the same name would otherwise silently collapse into "whichever one
|
||
/// happens to `set` last wins," discarding the other's formula with no
|
||
/// indication anything was lost. Both `preview` and the real Apply
|
||
/// (`OpenCADStudio::apply_named_parameter_editor_rows`) check this first and
|
||
/// refuse every row in a name collision rather than guessing which one the
|
||
/// user meant.
|
||
pub(crate) fn duplicate_name_rows(rows: &[ParamEditorRow]) -> std::collections::HashSet<usize> {
|
||
let mut by_name: std::collections::HashMap<&str, Vec<usize>> = std::collections::HashMap::new();
|
||
for (idx, row) in rows.iter().enumerate() {
|
||
let name = row.name.trim();
|
||
if !name.is_empty() {
|
||
by_name.entry(name).or_default().push(idx);
|
||
}
|
||
}
|
||
by_name
|
||
.into_values()
|
||
.filter(|idxs| idxs.len() > 1)
|
||
.flatten()
|
||
.collect()
|
||
}
|
||
|
||
/// Builds a scratch table from every row's *current* text and resolves each
|
||
/// row's own value against it, so the preview reflects cross-row references
|
||
/// (including a row referencing one defined further down — order in the
|
||
/// buffer doesn't matter, same as it doesn't for the real table). Returns
|
||
/// one `Result` per row, in row order:
|
||
/// - `Err` from a blank name is suppressed (`Ok` with no text shown instead,
|
||
/// handled by the caller) — a fresh row from "+ Add parameter" before the
|
||
/// user has typed a name yet isn't an error to flag.
|
||
/// - A name shared with another row is its own error (`duplicate_name_rows`)
|
||
/// before anything is even attempted.
|
||
/// - `Err` from `ParameterTable::set` itself (bad name, parse error, a
|
||
/// formula that would close a cycle right now) is that row's own problem.
|
||
/// - `Err(UndefinedReference)`/other resolve failure means the row's own
|
||
/// formula is fine but something *it* depends on isn't.
|
||
fn preview(rows: &[ParamEditorRow]) -> Vec<Option<Result<f64, String>>> {
|
||
let duplicates = duplicate_name_rows(rows);
|
||
let mut table = ParameterTable::new();
|
||
let mut set_errors: Vec<Option<String>> = vec![None; rows.len()];
|
||
for (idx, row) in rows.iter().enumerate() {
|
||
let name = row.name.trim();
|
||
if name.is_empty() || duplicates.contains(&idx) {
|
||
continue;
|
||
}
|
||
if let Err(e) = table.set(name, row.formula.trim()) {
|
||
set_errors[idx] = Some(e.to_string());
|
||
}
|
||
}
|
||
rows.iter()
|
||
.enumerate()
|
||
.map(|(idx, row)| {
|
||
let name = row.name.trim();
|
||
if name.is_empty() {
|
||
return None;
|
||
}
|
||
if duplicates.contains(&idx) {
|
||
return Some(Err(format!("duplicate name '{name}'")));
|
||
}
|
||
if let Some(err) = &set_errors[idx] {
|
||
return Some(Err(err.clone()));
|
||
}
|
||
Some(table.resolve(name).map_err(|e| e.to_string()))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Build the named-parameter editor content. `rows` is the live working
|
||
/// buffer; `scene` supplies the "Used by" column, computed against the
|
||
/// *live*, already-applied constraint set (`Scene::parameter_usage`) —
|
||
/// unapplied edits to a row's name in the buffer don't retroactively
|
||
/// relabel what's shown, same as the rest of this editor only takes effect
|
||
/// on Apply.
|
||
pub fn view_window<'a>(
|
||
rows: &'a [ParamEditorRow],
|
||
scene: &'a Scene,
|
||
sizing: crate::ui::modal::ModalSizing,
|
||
) -> Element<'a, Message> {
|
||
let title = text(t!("Named Parameters")).size(15);
|
||
let hint = text(t!(
|
||
"Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits."
|
||
))
|
||
.size(11)
|
||
.style(muted_style);
|
||
|
||
let gutter = iced::Padding {
|
||
top: 0.0,
|
||
right: GUTTER,
|
||
bottom: 0.0,
|
||
left: 0.0,
|
||
};
|
||
const NAME_WIDTH: f32 = 120.0;
|
||
const VALUE_WIDTH: f32 = 100.0;
|
||
const USED_BY_WIDTH: f32 = 160.0;
|
||
|
||
let head = container(
|
||
row![
|
||
container(text(t!("Name")).size(11).style(muted_style))
|
||
.width(Length::Fixed(NAME_WIDTH)),
|
||
container(text(t!("Formula")).size(11).style(muted_style)).width(sizing.width),
|
||
container(text(t!("Value")).size(11).style(muted_style))
|
||
.width(Length::Fixed(VALUE_WIDTH)),
|
||
container(text(t!("Used by")).size(11).style(muted_style))
|
||
.width(Length::Fixed(USED_BY_WIDTH)),
|
||
Space::new().width(Length::Fixed(30.0)),
|
||
]
|
||
.spacing(8),
|
||
)
|
||
.padding(gutter);
|
||
|
||
let results = preview(rows);
|
||
let mut list = column![].spacing(3);
|
||
for (idx, row) in rows.iter().enumerate() {
|
||
let name_box = text_input(t!("name").as_ref(), &row.name)
|
||
.on_input(move |v| Message::NamedParametersInput {
|
||
idx,
|
||
field: ParamField::Name,
|
||
value: v,
|
||
})
|
||
.size(13)
|
||
.padding([3, 6])
|
||
.width(Length::Fixed(NAME_WIDTH));
|
||
let formula_box = text_input(t!("formula").as_ref(), &row.formula)
|
||
.on_input(move |v| Message::NamedParametersInput {
|
||
idx,
|
||
field: ParamField::Formula,
|
||
value: v,
|
||
})
|
||
.size(13)
|
||
.padding([3, 6])
|
||
.width(sizing.width);
|
||
let value_cell: Element<'_, Message> = match results.get(idx).cloned().flatten() {
|
||
Some(Ok(value)) => {
|
||
container(text(format!("= {value:.4}")).size(12).style(muted_style)).into()
|
||
}
|
||
Some(Err(msg)) => container(text(msg).size(11).style(danger_style)).into(),
|
||
None => Space::new().into(),
|
||
};
|
||
let used_by_cell = used_by_cell(scene, row.name.trim());
|
||
let del = button(crate::ui::icons::themed_danger_text(
|
||
crate::ui::icons::CLOSE,
|
||
12.0,
|
||
))
|
||
.on_press(Message::NamedParametersRemove(idx))
|
||
.padding([2, 6])
|
||
.style(button::danger);
|
||
list = list.push(
|
||
row![
|
||
name_box,
|
||
formula_box,
|
||
container(value_cell).width(Length::Fixed(VALUE_WIDTH)),
|
||
container(used_by_cell).width(Length::Fixed(USED_BY_WIDTH)),
|
||
del
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
);
|
||
}
|
||
|
||
let add = button(text(t!("+ Add parameter")).size(12))
|
||
.on_press(Message::NamedParametersAdd)
|
||
.padding([4, 10])
|
||
.style(button::secondary);
|
||
|
||
let apply = button(text(t!("Apply")).size(12))
|
||
.on_press(Message::NamedParametersApply)
|
||
.padding([4, 16])
|
||
.style(button::primary);
|
||
|
||
container(
|
||
column![
|
||
title,
|
||
hint,
|
||
Space::new().height(6),
|
||
head,
|
||
scrollable(container(list).padding(gutter)).height(sizing.height),
|
||
Space::new().height(6),
|
||
row![add, Space::new().width(sizing.width), apply].align_y(iced::Center),
|
||
]
|
||
.spacing(6)
|
||
.width(sizing.width)
|
||
.height(sizing.height),
|
||
)
|
||
.padding(12)
|
||
.width(sizing.width)
|
||
.height(sizing.height)
|
||
.style(|theme: &Theme| container::Style {
|
||
background: Some(Background::Color(theme.palette().background.base.color)),
|
||
..Default::default()
|
||
})
|
||
.into()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn row(name: &str, formula: &str) -> ParamEditorRow {
|
||
ParamEditorRow {
|
||
name: name.to_string(),
|
||
formula: formula.to_string(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn preview_resolves_a_literal_and_a_cross_row_reference() {
|
||
let rows = vec![
|
||
row("hole_dia", "5"),
|
||
row("hole_spacing", "2 * hole_dia + 1.5"),
|
||
];
|
||
let results = preview(&rows);
|
||
assert_eq!(results[0], Some(Ok(5.0)));
|
||
assert_eq!(results[1], Some(Ok(11.5)));
|
||
}
|
||
|
||
#[test]
|
||
fn preview_resolves_a_forward_reference_regardless_of_row_order() {
|
||
// `plate_width` (row 0) references `plate_len` (row 1) -- defined
|
||
// *after* it in the buffer. Order in the editor must not matter, same
|
||
// as it doesn't for the real `ParameterTable`.
|
||
let rows = vec![row("plate_width", "plate_len / 2"), row("plate_len", "20")];
|
||
let results = preview(&rows);
|
||
assert_eq!(results[0], Some(Ok(10.0)));
|
||
assert_eq!(results[1], Some(Ok(20.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn preview_reports_a_cycle_on_the_row_that_closes_it() {
|
||
let rows = vec![row("a", "b"), row("b", "a")];
|
||
let results = preview(&rows);
|
||
// Row 0 (a = b) is accepted first (b doesn't exist yet, no cycle);
|
||
// row 1 (b = a) is the one that would close the loop and is rejected.
|
||
assert!(
|
||
matches!(results[0], Some(Ok(_)))
|
||
|| matches!(&results[0], Some(Err(e)) if e.contains("not defined"))
|
||
);
|
||
assert!(matches!(&results[1], Some(Err(e)) if e.to_lowercase().contains("circular")));
|
||
}
|
||
|
||
#[test]
|
||
fn preview_leaves_a_blank_row_without_a_name_unflagged() {
|
||
let rows = vec![row("", "")];
|
||
assert_eq!(preview(&rows), vec![None]);
|
||
}
|
||
|
||
#[test]
|
||
fn preview_reports_a_malformed_formula_on_its_own_row() {
|
||
let rows = vec![row("bad", "1 +")];
|
||
let results = preview(&rows);
|
||
assert!(matches!(&results[0], Some(Err(e)) if e.contains("formula error")));
|
||
}
|
||
|
||
#[test]
|
||
fn preview_flags_every_row_sharing_a_duplicate_name_instead_of_picking_one() {
|
||
let rows = vec![row("x", "1"), row("x", "2"), row("y", "3")];
|
||
let results = preview(&rows);
|
||
assert!(matches!(&results[0], Some(Err(e)) if e.contains("duplicate")));
|
||
assert!(matches!(&results[1], Some(Err(e)) if e.contains("duplicate")));
|
||
assert_eq!(
|
||
results[2],
|
||
Some(Ok(3.0)),
|
||
"an unrelated row's name must not be affected"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn usage_lines_names_the_constraint_kind_and_its_entities() {
|
||
use crate::scene::parametric_constraints::{
|
||
ConstraintKind, ParametricRef, ParametricScope,
|
||
};
|
||
let mut scene = Scene::new();
|
||
let line = scene.add_entity(codec::EntityType::Line(codec::entities::Line::from_points(
|
||
codec::types::Vector3::new(0.0, 0.0, 0.0),
|
||
codec::types::Vector3::new(10.0, 0.0, 0.0),
|
||
)));
|
||
scene
|
||
.parametric_constraint_set_mut(ParametricScope::ModelSpace)
|
||
.add(
|
||
ConstraintKind::Distance,
|
||
vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)],
|
||
Some(crate::scene::named_parameters::DrivingValue::Named(
|
||
"gap".to_string(),
|
||
)),
|
||
);
|
||
|
||
let lines = usage_lines(&scene, "gap");
|
||
assert_eq!(lines.len(), 1);
|
||
assert!(lines[0].starts_with("Distance: Line "), "got {lines:?}");
|
||
assert!(
|
||
lines[0].contains(&format!("{line}")),
|
||
"should name the actual entity handle, got {lines:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn usage_lines_is_empty_for_a_parameter_nothing_references() {
|
||
let scene = Scene::new();
|
||
assert!(usage_lines(&scene, "unused").is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn entity_label_reports_erased_for_a_dangling_handle() {
|
||
let scene = Scene::new();
|
||
let label = entity_label(&scene, codec::types::Handle::new(999));
|
||
assert!(label.starts_with("(erased "), "got {label}");
|
||
}
|
||
}
|