Add named parameters, UI scale setting, OPTIONS/OP dispatch, and help manual

Named parameters (docs/named_parameters_design.md, stages 3-4):
- ParameterTable (src/scene/named_parameters.rs): a document-wide table of
  named formulas, each resolved through a shared expression evaluator with
  cross-parameter references, cycle detection, and division-by-zero/parse
  error reporting. SketchConstraint::driving_param becomes DrivingValue
  (Literal | Named) instead of a bare f64, resolved at solve time by
  sketch_solve::build_constraint via Scene::named_parameters.
- Persistence (src/scene/named_parameters_persist.rs): same lazy XRecord
  model sketch_persist.rs already established, synced around open/save.
  Bumps the sketch-constraint XRecord's own format version (1 -> 2) since
  DrivingValue's shape doesn't line up with a version-1 blob's bytes.
- PARAMETERS command and its editor (src/ui/window/named_parameters.rs):
  add/remove/edit rows, live validation, duplicate-name detection: Apply
  defines the table and re-solves every constraint scope with a
  named-parameter-driven constraint (command_driver.rs). Distance/Angle
  constraint prompts (modules/draw/constrain/value.rs) now accept either a
  literal or an existing parameter's name, mirroring how the constraint's
  own value prompt already worked.
- Undo scope-down carried over from this project's own established pattern
  (constraint-system stage 9): the geometry an Apply moves is one undo
  step, but the parameter table edit itself has no undo entry yet — a
  documented follow-up, not a silent gap.

UI scale setting (src/app/settings.rs, app/mod.rs, ui/window/options.rs):
  a whole-interface scale percentage (50-200%), wired directly into iced's
  own per-window scale_factor hook rather than per-panel font-size
  plumbing, so it applies immediately to every panel, dialog, and the
  ribbon at once. Persisted alongside the existing lineweight-display-scale
  setting.

OPTIONS/OP command dispatch (app/commands/view.rs, app/commands/mod.rs):
  OPTIONS was registered for autocomplete but had no dispatch arm — the
  Start-tab welcome screen's own button was the only way to reach it.
  Found live-testing the UI-scale setting, unrelated to that feature
  itself. "OP" is AutoCAD's own short alias.

help/: a new self-contained static user-help manual (manual.md as the
authored source, tools/build_help.py splits it into the committed static
HTML pages plus search index) — not yet wired into the application.

64 new/updated tests across the named-parameters and constraint-prompt
changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-09 13:50:08 +02:00
1 parent e2d39b049d
commit 7cddf31bcb
47 files changed
+4136 -52

No files matched your search

+1
View File
@@ -18,3 +18,4 @@ pub mod options;
pub mod attribute_editor;
pub mod alias_editor;
pub mod find_replace;
pub mod named_parameters;
+258
View File
@@ -0,0 +1,258 @@
//! Named-parameter table editor — an in-canvas modal (Plan B), mirroring
//! `alias_editor.rs`'s buffered-rows pattern exactly. Opened by PARAMETERS
//! (`docs/named_parameters_design.md` stage 4). Rows are `(name, formula)`,
//! 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`/`sketch_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::t;
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Background, Element, Length, Theme};
/// Which column of a parameter row a text edit targets.
#[derive(Clone, Copy, Debug)]
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 muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style { color: Some(theme.palette().background.base.text.scale_alpha(0.68)) }
}
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.
pub fn view_window(rows: &[ParamEditorRow], sizing: crate::ui::modal::ModalSizing) -> Element<'_, 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 = 140.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)),
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 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)), 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");
}
}
+19
View File
@@ -41,6 +41,7 @@ pub fn view_window<'a>(
crosshair_color: Option<[u8; 3]>,
crosshair_color_input: &'a str,
lineweight_display_scale: i32,
ui_scale: i32,
ribbon_label_font_size: i32,
ribbon_group_title_font_size: i32,
model_space: &'a crate::app::config::ModelSpaceThemeConfig,
@@ -349,6 +350,24 @@ pub fn view_window<'a>(
});
let mut display = column![
text(crate::t!("UI Scale")).size(15),
Space::new().height(10),
row![
text(crate::t!("Overall size")).size(12).width(150),
slider(50..=200, ui_scale.clamp(50, 200), Message::UiScaleChanged)
.step(5)
.width(Fill),
text(format!("{}%", ui_scale.clamp(50, 200))).size(11).width(44),
]
.spacing(12)
.align_y(iced::Center),
Space::new().height(8),
text(crate::t!(
"Scales the whole interface at once — every panel, dialog, the ribbon, and their text — rather than one widget's font size. Takes effect immediately."
))
.size(11)
.width(sizing.width),
Space::new().height(24),
text(crate::tr!("options", "theme-section")).size(15),
Space::new().height(10),
row![