Harden parametric sketch constraints

This commit is contained in:
Hakan Seven committed 2026-09-11 09:45:57 +03:00
1 parent 41c07e9858
commit 9b58ca8a93
101 files changed
+3128 -13767

No files matched your search

+4 -12
View File
@@ -728,12 +728,7 @@ struct SelectionCanvas {
crosshair_bg: [f32; 4],
crosshair: CrosshairOptions,
selection_visual: SelectionVisualOptions,
/// One glyph per persistent sketch constraint in the current scope
/// (design doc §6.3/§7): screen anchor + label text (its symbol, plus a
/// dimensional kind's driving value — `sketch_constraints::glyph_label`)
/// + whether `solve_scope`'s `classify_redundant` call (stage 10/11)
/// flagged this constraint as redundant/conflicting — the design doc
/// §6.3's recommended "glyph color, not wire recoloring" state cue.
/// Constraint glyph anchor, label, and conflict state for the current scope.
constraint_glyphs: Vec<(Point, String, bool)>,
}
@@ -1630,18 +1625,15 @@ impl canvas::Program<Message> for SelectionCanvas {
frame.stroke(&b1, stroke.clone());
frame.stroke(&b2, stroke);
}
// Persistent sketch-constraint glyphs (design doc §6.3/§7): a small
// pill at each constraint's anchor point, its symbol (plus value for
// a dimensional kind) as text — visual-only for now, no hit-testing.
// Constraint glyphs are visual-only and have no hit testing.
if !self.constraint_glyphs.is_empty() {
const GLYPH_SIZE: f32 = 11.0;
const GLYPH_PAD_X: f32 = 5.0;
const GLYPH_PAD_Y: f32 = 2.0;
let normal_bg = theme.palette().primary.base.color;
let normal_fg = theme.palette().primary.base.text;
// Design doc §6.3's recommended color cue: a constraint
// `solve_scope` flagged as redundant/conflicting (stage 10/11's
// `classify_redundant`) gets the danger palette instead of the
// A redundant or conflicting constraint gets the danger palette
// instead of the
// ordinary primary one — same information a resolver panel
// would show, surfaced right on the geometry.
let conflict_bg = theme.palette().danger.base.color;
+1 -2
View File
@@ -1397,8 +1397,7 @@ impl PropertiesPanel {
}
/// One named-parameter row: editable name + formula, with the live-
/// resolved value (or error) shown alongside — the "AutoCAD style" the
/// Parameters section is modeled on, embedded in the panel instead of
/// resolved value (or error) shown alongside, embedded in the panel instead of
/// the old separate modal. Each field commits on submit (Enter / losing
/// focus), not per keystroke — `ParameterTable::set` validates
/// immediately and would otherwise reject a formula mid-type (the same
+3 -3
View File
@@ -133,9 +133,9 @@ pub(super) fn group_anchor<'a>(title: &str, content: Element<'a, Message>) -> El
}
}
pub(super) fn group_title<'a>(title: &'static str, open: &Option<String>, font_size: f32) -> Element<'a, Message> {
pub(super) fn group_title<'a>(title: &'static str, open: &Option<String>) -> Element<'a, Message> {
if title != "Draw" || TOOLS.is_empty() {
return container(text(t!(title)).size(font_size).style(muted_text_style))
return container(text(t!(title)).size(9).style(muted_text_style))
.padding([1, 4])
.into();
}
@@ -148,7 +148,7 @@ pub(super) fn group_title<'a>(title: &'static str, open: &Option<String>, font_s
PosReport::new(
PANEL_ID,
button(
row![text(t!(title)).size(font_size), arrow]
row![text(t!(title)).size(9), arrow]
.spacing(4)
.align_y(iced::Center),
)
+6 -51
View File
@@ -96,12 +96,6 @@ pub struct Ribbon {
tool_bar_h: Arc<AtomicU32>,
/// User-chosen panel density (persisted). `Auto` sizes by window width.
collapse_mode: CollapseMode,
/// Font size (px) of the caption under a large button's icon (persisted;
/// Options → Display → "Ribbon label size").
label_font_size: i32,
/// Font size (px) of a panel's own title, shown beneath its tools
/// (persisted; Options → Display → "Ribbon section title size").
group_title_font_size: i32,
/// Set by `CollapsePanels` when the tool row is in its tight state; the mode
/// selector hides itself then to give the cramped tab row its space back.
collapse_tight: Arc<AtomicBool>,
@@ -198,8 +192,6 @@ impl Ribbon {
tool_bar_h: Arc::new(AtomicU32::new(TOOL_BAR_H.to_bits())),
collapse_mode: CollapseMode::default(),
collapse_tight: Arc::new(AtomicBool::new(false)),
label_font_size: 10,
group_title_font_size: 9,
}
}
@@ -214,28 +206,6 @@ impl Ribbon {
self.collapse_mode
}
/// Change the icon-label font size. Persistence is handled by the caller
/// via the consolidated app config (`save_config`).
pub fn set_label_font_size(&mut self, size: i32) {
self.label_font_size = size.clamp(8, 16);
}
/// The current icon-label font size (for saving into the app config).
pub fn label_font_size(&self) -> i32 {
self.label_font_size
}
/// Change the panel-title font size. Persistence is handled by the
/// caller via the consolidated app config (`save_config`).
pub fn set_group_title_font_size(&mut self, size: i32) {
self.group_title_font_size = size.clamp(7, 14);
}
/// The current panel-title font size (for saving into the app config).
pub fn group_title_font_size(&self) -> i32 {
self.group_title_font_size
}
/// Current tab-bar height as last measured by the `WrapBar` widget.
fn tab_bar_height(&self) -> f32 {
f32::from_bits(self.tab_bar_h.load(Ordering::Relaxed))
@@ -633,8 +603,6 @@ impl Ribbon {
&self.active_linetype,
self.active_lineweight,
&style_ctx,
self.label_font_size,
self.group_title_font_size,
),
render_group(
true,
@@ -649,8 +617,6 @@ impl Ribbon {
&self.active_linetype,
self.active_lineweight,
&style_ctx,
self.label_font_size,
self.group_title_font_size,
),
collapse_button(
g,
@@ -665,7 +631,6 @@ impl Ribbon {
&self.active_linetype,
self.active_lineweight,
&style_ctx,
self.label_font_size,
false,
),
collapse_button(
@@ -681,14 +646,13 @@ impl Ribbon {
&self.active_linetype,
self.active_lineweight,
&style_ctx,
self.label_font_size,
true,
),
],
}
})
.collect();
CollapsePanels::new(panels, self.collapsed_open.clone(), widgets::tool_bar_h(self.group_title_font_size as f32))
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
.report_height(self.tool_bar_h.clone())
.report_tight(self.collapse_tight.clone())
.mode(self.collapse_mode)
@@ -1339,7 +1303,7 @@ impl Ribbon {
}
}
/// Render a single ribbon panel (tools + group label), `tool_bar_h(group_title_font_size)` tall.
/// Render a single ribbon panel (tools + group label), fixed `TOOL_BAR_H` tall.
/// When `compact`, large tools/dropdowns are drawn as small icon columns.
#[allow(clippy::too_many_arguments)]
fn render_group<'a>(
@@ -1355,8 +1319,6 @@ fn render_group<'a>(
active_linetype: &'a str,
active_lineweight: LineWeight,
style_ctx: &StyleContext<'_>,
label_font_size: i32,
group_title_font_size: i32,
) -> Element<'a, Message> {
let mut items_row: Vec<Element<Message>> = Vec::new();
let mut small_buf: Vec<Element<Message>> = Vec::new();
@@ -1373,7 +1335,6 @@ fn render_group<'a>(
active_lineweight,
style_ctx,
compact,
label_font_size: label_font_size as f32,
};
for item in &group.tools {
@@ -1411,12 +1372,12 @@ fn render_group<'a>(
draw_panel::group_anchor(group.title, column![
tools_el,
draw_panel::group_title(group.title, open_dd, group_title_font_size as f32),
draw_panel::group_title(group.title, open_dd),
]
.align_x(iced::Center)
.spacing(0)
.padding([3u16, 4])
.height(Length::Fixed(widgets::tool_bar_h(group_title_font_size as f32)))
.height(Length::Fixed(TOOL_BAR_H))
.into())
}
@@ -1479,7 +1440,6 @@ fn collapse_button<'a>(
active_linetype: &'a str,
active_lineweight: LineWeight,
style_ctx: &StyleContext<'_>,
label_font_size: i32,
// When set, this is the TIGHT step (reached once even the all-collapsed row
// overflows): the whole panel becomes a single non-running button — the first
// tool's icon + a ▾ that opens the tools flyout. Nothing runs directly here.
@@ -1520,9 +1480,7 @@ fn collapse_button<'a>(
.style(button::subtle)
.width(Fill)
.padding([3, 5]);
// This tight-mode panel *title* (not an icon caption) keeps its own
// fixed size regardless of the icon-label font-size setting.
return automatic_large_button(localized_title, content.into(), 9.0);
return automatic_large_button(localized_title, content.into());
}
// Collapsed (not yet tight): a large representative-tool face — a live button
@@ -1545,7 +1503,6 @@ fn collapse_button<'a>(
active_lineweight,
style_ctx,
compact: false,
label_font_size: label_font_size as f32,
},
)
}
@@ -1564,7 +1521,6 @@ fn collapse_button<'a>(
active_lineweight,
style_ctx,
compact: false,
label_font_size: label_font_size as f32,
},
)
}
@@ -1589,8 +1545,7 @@ fn collapse_button<'a>(
.style(button::subtle)
.width(Fill)
.padding([1, 4]);
// Same fixed-size opener title as the tight-mode branch above.
let opener = automatic_large_button(localized_title, opener.into(), 9.0);
let opener = automatic_large_button(localized_title, opener.into());
// The large face fills a fixed slot so a collapsed panel is shorter than a full
// 3-row panel, letting `CollapsePanels` shrink the ribbon row.
+28 -92
View File
@@ -56,40 +56,15 @@ pub(super) const LARGE_W: f32 = ROW_H * 2.2;
const LARGE_LABEL_HPAD: f32 = 8.0;
/// Large ribbon labels use at most two lines before their button grows.
const LARGE_LABEL_LINES: f32 = 2.0;
const LARGE_LABEL_SIZE: f32 = 10.0;
/// Width of a 1-row (small) button.
pub(super) const SMALL_W: f32 = ROW_H;
/// Width of the ▾ strip on a small dropdown.
pub(super) const ARROW_W: f32 = ROW_H * 0.4;
/// Height of the ▾ strip at the bottom of a large dropdown.
pub(super) const LARGE_ARR: f32 = ROW_H * 0.55;
/// Total ribbon tool-area height = 3 × ROW_H + 6 px v-padding + 12 px group-label,
/// at the default 9px group-title font size. Use [`tool_bar_h`] once the live
/// `group_title_font_size` setting is in scope — this constant is only the
/// floor `tool_bar_h` never drops below, kept as its own name for the few
/// call sites that predate the size becoming user-configurable.
/// Total ribbon tool-area height = 3 × ROW_H + 6 px v-padding + 12 px group-label.
pub(super) const TOOL_BAR_H: f32 = 3.0 * ROW_H + 18.0;
/// The group-title label's rendered height (`group_title_font_size == 9`)
/// baked into [`TOOL_BAR_H`]'s fixed 18px allowance (6px padding + this).
const GROUP_TITLE_LABEL_H_AT_DEFAULT: f32 = 12.0;
const GROUP_TITLE_FONT_SIZE_DEFAULT: f32 = 9.0;
/// Ribbon tool-area height for a given group-title font size.
///
/// The panel column that holds a group's icons above its title label is a
/// fixed height (`TOOL_BAR_H`, sized for the *default* title font). Raising
/// the "Section title size" option (`RibbonConfig::group_title_font_size`,
/// `src/app/config.rs`) grows the title text without growing that fixed
/// column, so the icon row above it — which fills whatever space is left —
/// gets squeezed and its icons visibly shrink. Scaling the label's share of
/// the height with the font size (proportionally to the default 12px
/// allowance at the default 9px size) keeps the icon row's share constant
/// instead. Clamped to never go *below* `TOOL_BAR_H`, so the default and
/// smaller settings render pixel-identically to before this existed.
pub(super) fn tool_bar_h(group_title_font_size: f32) -> f32 {
let label_h = GROUP_TITLE_LABEL_H_AT_DEFAULT * (group_title_font_size / GROUP_TITLE_FONT_SIZE_DEFAULT);
(3.0 * ROW_H + 6.0 + label_h).max(TOOL_BAR_H)
}
/// Height of a collapsed panel button's large representative face (big icon +
/// its label). A collapsed button is this face plus the title opener, so it is
/// shorter than a full 3-row panel — the ribbon height follows it down.
@@ -111,11 +86,8 @@ const COMBO_PANEL_PAD: Padding = Padding {
thread_local! {
/// Ribbon layout runs on every pointer-driven view update. Cache the font-
/// measured width per (translated label, font size) so the automatic
/// sizing stays cheap — keyed on both since the same label text is
/// measured at more than one size (a full-density button's own caption
/// vs. a collapsed panel's smaller opener title).
static LARGE_WIDTH_CACHE: RefCell<HashMap<(String, u32), f32>> =
/// measured width per translated label so the automatic sizing stays cheap.
static LARGE_WIDTH_CACHE: RefCell<HashMap<String, f32>> =
RefCell::new(HashMap::default());
}
@@ -124,7 +96,6 @@ fn ribbon_label_bounds(
label: &str,
width: f32,
wrapping: advanced_text::Wrapping,
label_size: f32,
) -> Size {
use advanced_text::{Paragraph as _, Renderer as _};
@@ -132,7 +103,7 @@ fn ribbon_label_bounds(
advanced_text::Text {
content: label,
bounds: Size::new(width, f32::INFINITY),
size: Pixels(label_size),
size: Pixels(LARGE_LABEL_SIZE),
line_height: advanced_text::LineHeight::default(),
font: renderer.default_font(),
align_x: advanced_text::Alignment::Center,
@@ -146,41 +117,27 @@ fn ribbon_label_bounds(
paragraph.min_bounds()
}
/// Widest a large button will grow to keep its label on one line before
/// falling back to two-line wrapping instead. A label pre-broken with an
/// explicit `\n` (long labels get hand-wrapped at the source, e.g. "Viewport
/// Configuration") measures its own natural per-line width regardless, so
/// this only ever engages for a plain label with no manual break.
const LARGE_LABEL_MAX_SINGLE_LINE_W: f32 = LARGE_W * 2.0;
/// Measure the translated label at the normal button width. Prefers widening
/// the button to keep the label on one line — most labels are short enough
/// for this to be a small, one-time bump — falling back to two-line wrapping
/// only once even a single line would need excessive width. The binary
/// search uses the renderer's real font metrics, so locale and UI scale
/// Measure the translated label at the normal button width. It wraps first;
/// only labels that would need more than two lines widen their button. The
/// binary search uses the renderer's real font metrics, so locale and UI scale
/// changes do not rely on character-count estimates.
fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32) -> f32 {
fn measure_large_width(renderer: &iced::Renderer, label: &str) -> f32 {
let base_inner = (LARGE_W - LARGE_LABEL_HPAD).max(1.0);
let line_height = advanced_text::LineHeight::default()
.to_absolute(Pixels(label_size))
.to_absolute(Pixels(LARGE_LABEL_SIZE))
.0;
// `Wrapping::Word` (not `WordOrGlyph`): a label that doesn't fit at this
// width wraps at the next word boundary instead of splitting mid-word.
// The width check catches the rare case Word-wrapping can't help — a
// single unbreakable "word" wider than the box — so that case correctly
// falls through to the wider stage below instead of reporting a false fit.
let fits_one_line = |width: f32| {
let max_label_height = line_height * LARGE_LABEL_LINES + 0.5;
let fits = |width: f32| {
let bounds = ribbon_label_bounds(
renderer,
label,
width,
advanced_text::Wrapping::Word,
label_size,
);
bounds.width <= width + 0.5 && bounds.height <= line_height + 0.5
bounds.width <= width + 0.5 && bounds.height <= max_label_height
};
if fits_one_line(base_inner) {
if fits(base_inner) {
return LARGE_W;
}
@@ -189,33 +146,18 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32)
label,
f32::INFINITY,
advanced_text::Wrapping::None,
label_size,
)
.width
.max(base_inner);
if natural <= LARGE_LABEL_MAX_SINGLE_LINE_W {
if !fits(natural) {
return (natural + LARGE_LABEL_HPAD).ceil();
}
// Long enough that a single line would make the button unreasonably
// wide — wrap to two lines instead, at the narrowest width (up to the
// same cap) that fits.
let max_label_height = line_height * LARGE_LABEL_LINES + 0.5;
let fits_two_lines = |width: f32| {
let bounds = ribbon_label_bounds(
renderer,
label,
width,
advanced_text::Wrapping::Word,
label_size,
);
bounds.width <= width + 0.5 && bounds.height <= max_label_height
};
let mut low = base_inner;
let mut high = LARGE_LABEL_MAX_SINGLE_LINE_W;
let mut high = natural;
for _ in 0..10 {
let mid = (low + high) * 0.5;
if fits_two_lines(mid) {
if fits(mid) {
high = mid;
} else {
low = mid;
@@ -224,15 +166,14 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32)
(high + LARGE_LABEL_HPAD).ceil().max(LARGE_W)
}
fn automatic_large_width(renderer: &iced::Renderer, label: &str, label_size: f32) -> f32 {
let key = (label.to_string(), label_size.to_bits());
if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| cache.borrow().get(&key).copied()) {
fn automatic_large_width(renderer: &iced::Renderer, label: &str) -> f32 {
if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| cache.borrow().get(label).copied()) {
return width;
}
let width = measure_large_width(renderer, label, label_size);
let width = measure_large_width(renderer, label);
LARGE_WIDTH_CACHE.with(|cache| {
cache.borrow_mut().insert(key, width);
cache.borrow_mut().insert(label.to_string(), width);
});
width
}
@@ -240,7 +181,6 @@ fn automatic_large_width(renderer: &iced::Renderer, label: &str, label_size: f32
struct AutomaticLargeWidth<'a> {
label: String,
content: Element<'a, Message>,
label_size: f32,
}
impl Widget<Message, Theme, iced::Renderer> for AutomaticLargeWidth<'_> {
@@ -266,7 +206,7 @@ impl Widget<Message, Theme, iced::Renderer> for AutomaticLargeWidth<'_> {
renderer: &iced::Renderer,
limits: &layout::Limits,
) -> layout::Node {
let width = automatic_large_width(renderer, &self.label, self.label_size);
let width = automatic_large_width(renderer, &self.label);
self.content.as_widget_mut().layout(
tree,
renderer,
@@ -346,9 +286,8 @@ impl Widget<Message, Theme, iced::Renderer> for AutomaticLargeWidth<'_> {
pub(super) fn automatic_large_button<'a>(
label: String,
content: Element<'a, Message>,
label_size: f32,
) -> Element<'a, Message> {
Element::new(AutomaticLargeWidth { label, content, label_size })
Element::new(AutomaticLargeWidth { label, content })
}
// ── Tab-bar constants ──────────────────────────────────────────────────────
@@ -693,9 +632,6 @@ pub(super) struct RenderCtx<'a> {
pub style_ctx: &'a StyleContext<'a>,
/// When compact, the Properties panel's Match button shrinks to a small icon.
pub compact: bool,
/// Font size (px) of the caption under a large button's icon (Options →
/// Display → "Ribbon label size").
pub label_font_size: f32,
}
/// A large dropdown button: the current icon on top, its ▾ directly beneath the
@@ -745,7 +681,7 @@ pub(super) fn render_large_dropdown<'a>(
.align_x(iced::Center)
.align_y(iced::Center),
text(label.clone())
.size(ctx.label_font_size)
.size(10)
.width(Fill)
.align_x(iced::Center)
.wrapping(advanced_text::Wrapping::Word),
@@ -798,7 +734,7 @@ pub(super) fn render_large_dropdown<'a>(
.width(Fill)
.height(Fill);
PosReport::new(id, automatic_large_button(label, content.into(), ctx.label_font_size))
PosReport::new(id, automatic_large_button(label, content.into()))
.into()
}
@@ -878,7 +814,7 @@ pub(super) fn render_large<'a>(
.align_x(iced::Center)
.align_y(iced::Center),
text(label.clone())
.size(ctx.label_font_size)
.size(10)
.width(Fill)
.align_x(iced::Center)
.wrapping(advanced_text::Wrapping::Word),
@@ -899,7 +835,7 @@ pub(super) fn render_large<'a>(
left: 4.0,
});
tooltip(
automatic_large_button(label, btn.into(), ctx.label_font_size),
automatic_large_button(label, btn.into()),
make_tip(tip_text),
TipPos::Right,
)
+6 -13
View File
@@ -125,13 +125,13 @@ impl StatusBar {
// Which pills the user has chosen to show on the bar.
config: &'a StatusBarConfig,
menu_data: StatusMenuData<'a>,
// Remaining degrees of freedom for the current sketch scope's
// persistent constraints (design doc §6.3) — `None` when the scope
// Remaining degrees of freedom for the current sketch scope.
// `None` when the scope
// has no `SketchConstraintSet` yet (nothing constrained), so the
// badge stays invisible until it's actually relevant.
sketch_dof: Option<usize>,
// Number of redundant/conflicting constraints `solve_scope` flagged
// in the current sketch scope (design doc §6.4, stage 11) — 0 hides
// Number of redundant/conflicting constraints in the current scope.
// Zero hides
// the pill entirely, so an ordinarily/fully-constrained drawing sees
// no new clutter.
sketch_conflicts: usize,
@@ -274,11 +274,7 @@ impl StatusBar {
.into(),
);
}
// Design doc §6.4 (stage 11): a bounded v1 of the guided conflict
// resolver — click removes one flagged constraint at a time. See
// `OpenCADStudio::resolve_one_sketch_conflict`'s doc comment for the
// full scope-down rationale versus the design doc's named-candidate,
// cyclable-preview panel.
// Each click removes one flagged constraint.
if sketch_conflicts > 0 {
pills.push(
tip(
@@ -1082,10 +1078,7 @@ fn status_pill(label: impl Into<String>) -> Element<'static, Message> {
.into()
}
/// Like [`status_pill`], but with the theme's `success` accent — design doc
/// §6.3's "simple color-coded constraint state" cue: used for the DOF badge
/// when a sketch scope has reached zero remaining degrees of freedom, so
/// "fully constrained" is visible at a glance without opening anything.
/// A success-colored status pill used when no degrees of freedom remain.
fn success_pill(label: impl Into<String>) -> Element<'static, Message> {
container(text(label.into()).size(12))
.style(|theme: &Theme| {
+104 -39
View File
@@ -1,6 +1,4 @@
//! 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)`,
//! 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
@@ -25,9 +23,7 @@ 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 one referenced entity — the same handle
/// display (`0x…`) AutoCAD/this app's own conventions use, since entities
/// have no user-facing name to show instead.
/// 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}"),
@@ -46,7 +42,12 @@ fn usage_lines(scene: &Scene, name: &str) -> Vec<String> {
.parameter_usage(name)
.iter()
.map(|u| {
let entities = u.entities.iter().map(|h| entity_label(scene, *h)).collect::<Vec<_>>().join(", ");
let entities = u
.entities
.iter()
.map(|h| entity_label(scene, *h))
.collect::<Vec<_>>()
.join(", ");
format!("{:?}: {entities}", u.kind)
})
.collect()
@@ -74,14 +75,20 @@ fn used_by_cell<'a>(scene: &Scene, name: &str) -> Element<'a, Message> {
None => counts.push((kind, 1)),
}
}
let summary = counts.iter().map(|(k, n)| format!("{k} ×{n}")).collect::<Vec<_>>().join(", ");
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]),
container(body)
.style(container::bordered_box)
.padding([4, 8]),
TipPos::Top,
)
.into()
@@ -107,11 +114,15 @@ pub struct ParamEditorRow {
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)) }
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) }
iced::widget::text::Style {
color: Some(theme.palette().danger.base.color),
}
}
/// Every buffer index sharing a (trimmed) name with at least one other
@@ -130,7 +141,11 @@ pub(crate) fn duplicate_name_rows(rows: &[ParamEditorRow]) -> std::collections::
by_name.entry(name).or_default().push(idx);
}
}
by_name.into_values().filter(|idxs| idxs.len() > 1).flatten().collect()
by_name
.into_values()
.filter(|idxs| idxs.len() > 1)
.flatten()
.collect()
}
/// Builds a scratch table from every row's *current* text and resolves each
@@ -184,7 +199,11 @@ fn preview(rows: &[ParamEditorRow]) -> Vec<Option<Result<f64, String>>> {
/// 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> {
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."
@@ -192,17 +211,25 @@ pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: cra
.size(11)
.style(muted_style);
let gutter = iced::Padding { top: 0.0, right: GUTTER, bottom: 0.0, left: 0.0 };
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!("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)),
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),
@@ -213,25 +240,38 @@ pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: cra
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 })
.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 })
.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(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);
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,
@@ -272,7 +312,10 @@ pub fn view_window<'a>(rows: &'a [ParamEditorRow], scene: &'a Scene, sizing: cra
.padding(12)
.width(sizing.width)
.height(sizing.height)
.style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.base.color)), ..Default::default() })
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.base.color)),
..Default::default()
})
.into()
}
@@ -281,12 +324,18 @@ mod tests {
use super::*;
fn row(name: &str, formula: &str) -> ParamEditorRow {
ParamEditorRow { name: name.to_string(), formula: formula.to_string() }
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 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)));
@@ -309,7 +358,10 @@ mod tests {
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[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")));
}
@@ -332,27 +384,40 @@ mod tests {
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");
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::sketch_constraints::{ConstraintKind, SketchRef, SketchScope};
let mut scene = Scene::new();
let line = scene.add_entity(acadrust::EntityType::Line(acadrust::entities::Line::from_points(
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
)));
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
ConstraintKind::Distance,
vec![SketchRef::point(line, 0), SketchRef::point(line, 1)],
Some(crate::scene::named_parameters::DrivingValue::Named("gap".to_string())),
);
let line = scene.add_entity(acadrust::EntityType::Line(
acadrust::entities::Line::from_points(
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
),
));
scene
.sketch_constraint_set_mut(SketchScope::ModelSpace)
.add(
ConstraintKind::Distance,
vec![SketchRef::point(line, 0), SketchRef::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:?}");
assert!(
lines[0].contains(&format!("{line}")),
"should name the actual entity handle, got {lines:?}"
);
}
#[test]
+3 -76
View File
@@ -163,9 +163,6 @@ 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,
model_bg_input: &'a str,
paper_bg_input: &'a str,
@@ -359,16 +356,14 @@ pub fn view_window<'a>(
iced::widget::checkbox(write_dwg_native_constraints)
.on_toggle(Message::WriteDwgNativeConstraintsChanged)
.size(15),
text(crate::t!("Write AutoCAD-compatible constraint objects on save")).size(12),
text(crate::t!("Write native constraint objects on save")).size(12),
]
.spacing(8)
.align_y(iced::Center),
Space::new().height(6),
text(crate::t!(
"Saves sketch constraints as AutoCAD/BricsCAD's own native objects too, alongside \
this app's own format, so other CAD software recognizes them. Off by default — \
adds some file size on every save. A drawing that already has these objects keeps \
them up to date regardless of this setting."
"Saves sketch constraints as native drawing objects alongside this app's own format. \
Off by default because it adds file size. Existing native objects stay synchronized."
))
.size(11)
.width(sizing.width),
@@ -596,24 +591,6 @@ 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![
@@ -871,56 +848,6 @@ pub fn view_window<'a>(
))
.size(11)
.width(sizing.width),
)
.push(Space::new().height(24))
.push(text(crate::t!("Ribbon")).size(15))
.push(Space::new().height(10))
.push(
row![
text(crate::t!("Icon label size")).size(12).width(140),
slider(
8..=16,
ribbon_label_font_size.clamp(8, 16),
Message::RibbonLabelFontSizeChanged,
)
.step(1)
.width(Fill),
text(format!("{}px", ribbon_label_font_size.clamp(8, 16)))
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
)
.push(Space::new().height(6))
.push(
text(crate::t!("Font size of the caption under each ribbon button's icon."))
.size(11)
.width(sizing.width),
)
.push(Space::new().height(10))
.push(
row![
text(crate::t!("Section title size")).size(12).width(140),
slider(
7..=14,
ribbon_group_title_font_size.clamp(7, 14),
Message::RibbonGroupTitleFontSizeChanged,
)
.step(1)
.width(Fill),
text(format!("{}px", ribbon_group_title_font_size.clamp(7, 14)))
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
)
.push(Space::new().height(6))
.push(
text(crate::t!("Font size of each panel's own title (Draw, Modify, Layers, …)."))
.size(11)
.width(sizing.width),
);
let display_element = display.spacing(0).width(sizing.width);