forked from 1b-agent/opencadstudio-upstream
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:
1 parent
e2d39b049d
commit
7cddf31bcb
47 files changed
+4136
-52
No files matched your search
@@ -18,3 +18,4 @@ pub mod options;
|
||||
pub mod attribute_editor;
|
||||
pub mod alias_editor;
|
||||
pub mod find_replace;
|
||||
pub mod named_parameters;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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![
|
||||
|
||||
Reference in new issue
Block a user