Graphic Attributes: tidy helpers, split tests, translate the palette

- The hatch pattern picker takes its messages as one PatternPickerMessages
  struct instead of eight arguments.
- The palette's fill index is keyed on the scene's geometry epoch alone;
  epochs are unique across scenes, so the scene address is not needed.
- The ribbon lineweight handler is an on_ribbon_lineweight_changed method,
  used by the palette instead of a recursive update() call.
- The 430-line palette test is split into eight focused tests with shared
  helpers; no checks were dropped.
- Palette, gradient editor, prompts, messages and undo labels go through
  t!/tf!. The 33 new strings are in a graphic-attributes Fluent group in
  all 21 locales; "[Yes / No] <Yes>", ByLayer and ByBlock stay English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgkJN8DEojuPTumAXLHotu
This commit is contained in:
Claude committed 2026-10-07 18:38:04 +00:00
1 parent 119131ee10
commit c21a1e6538
28 files changed
+1060 -482

No files matched your search

+26
View File
@@ -2155,6 +2155,32 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
Task::none()
}
pub(super) fn on_ribbon_lineweight_changed(&mut self, lw: acadrust::types::LineWeight) -> Task<Message> {
let i = self.active_tab;
self.ribbon.close_dropdown();
let handles = self.property_target_handles(i);
if handles.is_empty() {
if self.has_property_selection(i) {
return Task::none();
}
// Persist into the tab's header (CELWEIGHT). #21.
self.tabs[i].scene.document.header.current_line_weight = lw.value();
self.tabs[i].dirty = true;
self.ribbon.active_lineweight = lw;
} else {
// Lineweight is baked into the cached wire geometry —
// re-tessellate so the change shows immediately (issue #231
// class).
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::view::dispatch::apply_line_weight(entity, lw);
}
});
self.ribbon.active_lineweight = lw;
}
Task::none()
}
pub(super) fn on_ribbon_linetype_changed(&mut self, lt: String) -> Task<Message> {
let i = self.active_tab;
self.ribbon.close_dropdown();
+184 -396
View File
@@ -305,7 +305,7 @@ impl OpenCADStudio {
"" | "Y" | "YES" => self.on_graphic_fill_close_response(true),
"N" | "NO" => self.on_graphic_fill_close_response(false),
_ => {
self.command_line.push_error("Enter Yes or No.");
self.command_line.push_error(crate::t!("Enter Yes or No.").as_ref());
Task::none()
}
}
@@ -422,7 +422,7 @@ impl OpenCADStudio {
self.set_current_graphic_attributes(color, linetype.to_owned(), lineweight, transparency);
return Task::none();
}
self.edit_graphic_part(Part::Both, "Graphic Attributes", |entity| {
self.edit_graphic_part(Part::Both, &crate::t!("Graphic Attributes"), |entity| {
crate::scene::view::dispatch::apply_color(entity, color);
crate::scene::view::dispatch::apply_common_prop(entity, "linetype", linetype);
crate::scene::view::dispatch::apply_line_weight(entity, lineweight);
@@ -463,7 +463,7 @@ impl OpenCADStudio {
})
.collect();
let handles: Vec<_> = values.keys().copied().collect();
self.apply_property_op(i, "Graphic Attributes", &handles, |app, handle| {
self.apply_property_op(i, crate::t!("Graphic Attributes"), &handles, |app, handle| {
let (Some(values), Some(entity)) = (
values.get(&handle),
app.tabs[i].scene.document.get_entity_mut(handle),
@@ -483,7 +483,7 @@ impl OpenCADStudio {
let name = raw_name.trim();
if !crate::scene::valid_block_name(name) {
self.command_line
.push_error("Graphic Attributes: enter a valid layer name without <>/\\\":;?*|,=`.");
.push_error(crate::t!("Graphic Attributes: enter a valid layer name without <>/\\\":;?*|,=`.").as_ref());
return Task::none();
}
if self.tabs[i]
@@ -503,7 +503,7 @@ impl OpenCADStudio {
let layer_name = name.to_owned();
let undo = self.begin_layer_undo(
i,
"Create layer with active settings",
crate::t!("Create Layer with active settings"),
std::slice::from_ref(&layer_name),
);
let mut layer = acadrust::tables::Layer::new(name);
@@ -514,7 +514,7 @@ impl OpenCADStudio {
layer.transparency = values.transparency;
if self.tabs[i].scene.document.layers.add(layer).is_err() {
self.command_line
.push_error("Graphic Attributes: the layer could not be created.");
.push_error(crate::t!("Graphic Attributes: the layer could not be created.").as_ref());
return Task::none();
}
self.tabs[i].dirty = true;
@@ -614,12 +614,12 @@ impl OpenCADStudio {
.any(|handle| self.tabs[i].scene.is_layer_locked(*handle))
{
self.command_line
.push_error("Gradient Fill: the fill is on a locked layer.");
.push_error(crate::t!("Gradient Fill: the fill is on a locked layer.").as_ref());
self.graphic_attributes.gradient_editor = Some(state);
return Task::none();
}
let angle = state.angle_radians();
let pending = self.begin_undo(i, "Edit gradient", targets.len(), true);
let pending = self.begin_undo(i, crate::t!("Edit gradient"), targets.len(), true);
for handle in &targets {
let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else {
continue;
@@ -731,7 +731,7 @@ impl OpenCADStudio {
);
}
let selected = selected.as_slice();
let pending = self.begin_undo(i, "Graphic Attributes", selected.len(), true);
let pending = self.begin_undo(i, crate::t!("Graphic Attributes"), selected.len(), true);
let mut changed = false;
for handle in to_close {
let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else {
@@ -768,7 +768,7 @@ impl OpenCADStudio {
} else {
let Some((name, pattern)) = fill_pattern(value) else {
self.command_line
.push_error("Graphic Attributes: no hatch pattern is available.");
.push_error(crate::t!("Graphic Attributes: no hatch pattern is available.").as_ref());
return false;
};
let working = if self.tabs[i].editing_model_space() {
@@ -867,16 +867,17 @@ impl OpenCADStudio {
.any(|handle| self.tabs[i].scene.is_layer_locked(*handle))
{
self.command_line
.push_error("Graphic Attributes: an associated fill is on a locked layer.");
.push_error(crate::t!("Graphic Attributes: an associated fill is on a locked layer.").as_ref());
return false;
}
if skipped == 1 {
self.command_line
.push_info("Graphic Attributes: 1 unsupported object was not changed.");
.push_info(crate::t!("Graphic Attributes: 1 unsupported object was not changed.").as_ref());
} else if skipped > 1 {
self.command_line.push_info(&format!(
"Graphic Attributes: {skipped} unsupported objects were not changed."
));
self.command_line.push_info(
crate::tf!("Graphic Attributes: {skipped} unsupported objects were not changed.")
.as_ref(),
);
}
if associated.is_empty() && new_hatches.is_empty() && updated_hatches.is_empty() {
return false;
@@ -892,14 +893,14 @@ impl OpenCADStudio {
}
pub(super) fn on_solid_fill_color(&mut self, color: AcadColor) -> Task<Message> {
self.edit_graphic_part(Part::Fill, "Fill color", |entity| {
self.edit_graphic_part(Part::Fill, &crate::t!("Fill color"), |entity| {
crate::scene::view::dispatch::apply_color(entity, color);
});
Task::none()
}
pub(super) fn on_fill_transparency(&mut self, transparency: Transparency) -> Task<Message> {
self.edit_graphic_part(Part::Fill, "Fill transparency", |entity| {
self.edit_graphic_part(Part::Fill, &crate::t!("Fill transparency"), |entity| {
entity.common_mut().transparency = transparency;
});
Task::none()
@@ -909,7 +910,7 @@ impl OpenCADStudio {
if !self.has_selection() {
return self.on_ribbon_color_changed(color);
}
self.edit_graphic_part(Part::Line, "Line color", |entity| {
self.edit_graphic_part(Part::Line, &crate::t!("Line color"), |entity| {
crate::scene::view::dispatch::apply_color(entity, color);
});
Task::none()
@@ -919,7 +920,7 @@ impl OpenCADStudio {
if !self.has_selection() {
return self.on_ribbon_linetype_changed(linetype);
}
self.edit_graphic_part(Part::Line, "Line linetype", |entity| {
self.edit_graphic_part(Part::Line, &crate::t!("Line linetype"), |entity| {
crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &linetype);
});
Task::none()
@@ -927,9 +928,9 @@ impl OpenCADStudio {
pub(super) fn on_line_lineweight(&mut self, lineweight: LineWeight) -> Task<Message> {
if !self.has_selection() {
return self.update(Message::RibbonLineweightChanged(lineweight));
return self.on_ribbon_lineweight_changed(lineweight);
}
self.edit_graphic_part(Part::Line, "Line weight", |entity| {
self.edit_graphic_part(Part::Line, &crate::t!("Line weight"), |entity| {
crate::scene::view::dispatch::apply_line_weight(entity, lineweight);
});
Task::none()
@@ -946,7 +947,7 @@ impl OpenCADStudio {
self.refresh_properties();
return Task::none();
}
self.edit_graphic_part(Part::Line, "Linetype scale", |entity| {
self.edit_graphic_part(Part::Line, &crate::t!("Linetype scale"), |entity| {
entity.common_mut().linetype_scale = scale;
});
Task::none()
@@ -965,7 +966,7 @@ impl OpenCADStudio {
}
return Task::none();
}
self.edit_graphic_part(Part::Line, "Line transparency", |entity| {
self.edit_graphic_part(Part::Line, &crate::t!("Line transparency"), |entity| {
entity.common_mut().transparency = transparency;
});
Task::none()
@@ -988,113 +989,114 @@ mod tests {
app
}
#[test]
fn graphic_attributes_creates_one_associative_fill_per_object() {
let mut app = fresh();
let i = app.active_tab;
let mut boundaries = Vec::new();
// Coincident boundaries must still own independent associations.
for offset in [0.0, 0.0] {
let mut polyline = LwPolyline::new();
for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] {
polyline.add_point(Vector2::new(x + offset, y));
}
polyline.close();
let handle = app.tabs[i]
.scene
.add_entity(EntityType::LwPolyline(polyline));
app.tabs[i].scene.select_entity(handle, false);
boundaries.push(handle);
fn add_closed_square(app: &mut OpenCADStudio, x: f64) -> Handle {
let mut polyline = LwPolyline::new();
for (px, py) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] {
polyline.add_point(Vector2::new(px + x, py));
}
polyline.close();
let i = app.active_tab;
app.tabs[i].scene.add_entity(EntityType::LwPolyline(polyline))
}
let _ = app.on_graphic_attribute(GraphicAttribute::Solid);
let hatches: Vec<_> = app.tabs[i]
fn select(app: &mut OpenCADStudio, handles: &[Handle]) {
let i = app.active_tab;
app.tabs[i].scene.deselect_all();
for handle in handles {
app.tabs[i].scene.select_entity(*handle, false);
}
}
fn hatches(app: &OpenCADStudio) -> Vec<&acadrust::entities::Hatch> {
app.tabs[app.active_tab]
.scene
.document
.entities()
.filter_map(|entity| {
let EntityType::Hatch(hatch) = entity else {
return None;
};
Some(hatch)
.filter_map(|entity| match entity {
EntityType::Hatch(hatch) => Some(hatch),
_ => None,
})
.collect();
assert_eq!(hatches.len(), 2);
assert!(hatches
.iter()
.all(|hatch| hatch.is_solid && hatch.is_associative));
assert!(hatches.iter().all(|hatch| hatch
.collect()
}
fn common(app: &OpenCADStudio, handle: Handle) -> &acadrust::entities::EntityCommon {
app.tabs[app.active_tab]
.scene
.document
.get_entity(handle)
.expect("entity")
.common()
}
fn fill_kind(app: &OpenCADStudio, handle: Handle) -> GraphicAttribute {
palette::current(&app.tabs[app.active_tab].scene.document, &[handle])
}
/// A closed square with a fill of `kind`; the fill HATCH is selected.
fn filled_square(app: &mut OpenCADStudio, kind: GraphicAttribute) -> (Handle, Handle) {
let boundary = add_closed_square(app, 0.0);
select(app, &[boundary]);
let _ = app.on_graphic_attribute(kind);
let hatch = hatches(app)[0].common.handle;
select(app, &[hatch]);
(boundary, hatch)
}
#[test]
fn coincident_boundaries_each_get_their_own_associative_fill() {
let mut app = fresh();
let boundaries = [add_closed_square(&mut app, 0.0), add_closed_square(&mut app, 0.0)];
select(&mut app, &boundaries);
let _ = app.on_graphic_attribute(GraphicAttribute::Solid);
let fills = hatches(&app);
assert_eq!(fills.len(), 2);
assert!(fills.iter().all(|hatch| hatch.is_solid && hatch.is_associative));
assert!(fills.iter().all(|hatch| hatch
.paths
.iter()
.flat_map(|path| &path.boundary_handles)
.count()
== 1));
app.tabs[i].scene.deselect_all();
app.tabs[i].scene.select_entity(boundaries[0], false);
select(&mut app, &boundaries[..1]);
let _ = app.on_graphic_attribute(GraphicAttribute::Gradient);
assert_eq!(fill_kind(&app, boundaries[0]), GraphicAttribute::Gradient);
assert_eq!(fill_kind(&app, boundaries[1]), GraphicAttribute::Solid);
assert_eq!(hatches(&app).len(), 2);
}
#[test]
fn unsupported_objects_are_skipped_while_the_rest_is_filled() {
let mut app = fresh();
let i = app.active_tab;
let boundaries = [add_closed_square(&mut app, 0.0), add_closed_square(&mut app, 20.0)];
select(&mut app, &boundaries);
let _ = app.on_graphic_attribute(GraphicAttribute::Gradient);
let hatches: Vec<_> = app.tabs[i]
.scene
.document
.entities()
.filter_map(|entity| {
let EntityType::Hatch(hatch) = entity else {
return None;
};
Some(hatch)
})
.collect();
assert_eq!(hatches.len(), 2);
assert_eq!(
hatches
.iter()
.filter(|hatch| hatch.gradient_color.enabled)
.count(),
1
);
assert_eq!(
hatches
.iter()
.filter(|hatch| hatch.is_solid && !hatch.gradient_color.enabled)
.count(),
1
);
// An invalid boundary is skipped while valid selected boundaries are
// still updated.
let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new()));
app.tabs[i].scene.select_entity(line, false);
select(&mut app, &[boundaries[0], line]);
let _ = app.on_graphic_attribute(GraphicAttribute::Solid);
let hatches: Vec<_> = app.tabs[i]
.scene
.document
.entities()
.filter_map(|entity| {
let EntityType::Hatch(hatch) = entity else {
return None;
};
Some(hatch)
})
.collect();
assert_eq!(hatches.len(), 2);
assert!(hatches
.iter()
.all(|hatch| hatch.is_solid && !hatch.gradient_color.enabled));
assert!(hatches.iter().all(|hatch| hatch
.paths
.iter()
.flat_map(|path| &path.boundary_handles)
.all(|handle| *handle != line)));
assert_eq!(fill_kind(&app, boundaries[0]), GraphicAttribute::Solid);
assert_eq!(fill_kind(&app, boundaries[1]), GraphicAttribute::Gradient);
assert_eq!(fill_kind(&app, line), GraphicAttribute::None);
assert_eq!(hatches(&app).len(), 2);
}
// Editing a common gradient updates each object's independent hatch
// and records the whole operation as one undo step.
app.tabs[i].scene.deselect_all();
for handle in &boundaries {
app.tabs[i].scene.select_entity(*handle, false);
}
#[test]
fn editing_a_shared_gradient_is_one_undo_step() {
let mut app = fresh();
let i = app.active_tab;
let boundaries = [add_closed_square(&mut app, 0.0), add_closed_square(&mut app, 20.0)];
select(&mut app, &boundaries);
let _ = app.on_graphic_attribute(GraphicAttribute::Gradient);
app.open_gradient_editor();
let editor = app.graphic_attributes.gradient_editor.as_mut().expect("gradient editor");
let editor = app
.graphic_attributes
.gradient_editor
.as_mut()
.expect("gradient editor");
assert_eq!(editor.handles.len(), 2);
editor.kind = crate::scene::model::hatch_model::GradientKind::Curved;
editor.inverted = true;
@@ -1104,319 +1106,105 @@ mod tests {
let _ = app.apply_gradient_editor();
assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1);
let gradients: Vec<_> = app.tabs[i]
.scene
.document
.entities()
.filter_map(|entity| {
let EntityType::Hatch(hatch) = entity else {
return None;
};
hatch.gradient_color.enabled.then_some(hatch)
})
.collect();
let gradients = hatches(&app);
assert_eq!(gradients.len(), 2);
assert!(gradients.iter().all(|hatch| {
hatch.gradient_color.name == "INVCURVED"
&& (hatch.gradient_color.angle.to_degrees() - 45.0).abs() < 1.0e-9
&& (hatch.gradient_color.shift - 1.0).abs() < 1.0e-9
}));
}
// A selection change closes the flyout and it remains closed until
// the user explicitly opens it again.
#[test]
fn gradient_editor_closes_when_the_selection_changes() {
let mut app = fresh();
filled_square(&mut app, GraphicAttribute::Gradient);
app.open_gradient_editor();
assert!(app.graphic_attributes.gradient_editor.is_some());
app.tabs[i].scene.deselect_all();
app.tabs[app.active_tab].scene.deselect_all();
let _ = app.update(ga(GraphicAttributesMsg::CloseMenu));
assert!(app.graphic_attributes.gradient_editor.is_none());
}
let selected_hatch = app.tabs[i]
.scene
.document
.entities()
.find_map(|entity| {
matches!(entity, EntityType::Hatch(_)).then_some(entity.common().handle)
})
.expect("gradient hatch");
let associated_lines = palette::line_handles(
&app.tabs[i].scene.document,
&[selected_hatch],
);
assert_eq!(associated_lines.len(), 1);
assert!(boundaries.contains(&associated_lines[0]));
assert_eq!(
palette::current(
&app.tabs[i].scene.document,
&[selected_hatch],
),
GraphicAttribute::Gradient,
);
app.tabs[i].scene.deselect_all();
app.tabs[i].scene.select_entity(selected_hatch, false);
#[test]
fn a_selected_hatch_is_converted_in_place() {
let mut app = fresh();
let (boundary, hatch) = filled_square(&mut app, GraphicAttribute::Gradient);
let document = &app.tabs[app.active_tab].scene.document;
assert_eq!(palette::line_handles(document, &[hatch]), vec![boundary]);
assert_eq!(fill_kind(&app, hatch), GraphicAttribute::Gradient);
// A directly selected hatch is edited in place instead of being
// rejected as an unsupported boundary object.
let _ = app.on_graphic_attribute(GraphicAttribute::Solid);
assert_eq!(
palette::current(
&app.tabs[i].scene.document,
&[selected_hatch],
),
GraphicAttribute::Solid,
);
let _ = app.on_solid_fill_color(acadrust::types::Color::Index(3));
assert_eq!(
app.tabs[i]
.scene
.document
.get_entity(selected_hatch)
.expect("solid hatch")
.common()
.color,
acadrust::types::Color::Index(3),
);
let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.35));
assert!(
(app.tabs[i]
.scene
.document
.get_entity(selected_hatch)
.expect("transparent solid hatch")
.common()
.transparency
.as_percent()
- 0.35)
.abs()
< 0.01
);
let _ = app.on_fill_transparency(acadrust::types::Transparency::BY_LAYER);
assert!(app.tabs[i]
.scene
.document
.get_entity(selected_hatch)
.expect("ByLayer solid hatch")
.common()
.transparency
.is_by_layer());
for kind in [GraphicAttribute::Solid, GraphicAttribute::Hatch, GraphicAttribute::Gradient] {
let _ = app.on_graphic_attribute(kind);
assert_eq!(fill_kind(&app, hatch), kind);
}
assert_eq!(hatches(&app).len(), 1);
let pattern = default_hatch_pattern().expect("pattern hatch").name.clone();
let _ = app.update(ga(GraphicAttributesMsg::HatchPattern(pattern.clone())));
assert_eq!(hatches(&app)[0].pattern.name, pattern);
}
#[test]
fn line_and_fill_controls_only_edit_their_own_part() {
let mut app = fresh();
let i = app.active_tab;
let (_, hatch) = filled_square(&mut app, GraphicAttribute::Solid);
let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new()));
select(&mut app, &[hatch, line]);
let _ = app.on_solid_fill_color(AcadColor::Index(3));
let _ = app.on_line_color(AcadColor::Index(4));
assert_eq!(common(&app, hatch).color, AcadColor::Index(3));
assert_eq!(common(&app, line).color, AcadColor::Index(4));
// Line controls ignore HATCH entities, while fill controls ignore
// ordinary geometry in the same mixed selection.
app.tabs[i].scene.select_entity(line, false);
let _ = app.on_line_color(acadrust::types::Color::Index(4));
assert_eq!(
app.tabs[i]
.scene
.document
.get_entity(line)
.expect("line")
.common()
.color,
acadrust::types::Color::Index(4),
);
assert_eq!(
app.tabs[i]
.scene
.document
.get_entity(selected_hatch)
.expect("solid hatch")
.common()
.color,
acadrust::types::Color::Index(3),
);
let _ = app.on_solid_fill_color(acadrust::types::Color::Index(5));
assert_eq!(
app.tabs[i]
.scene
.document
.get_entity(selected_hatch)
.expect("solid hatch")
.common()
.color,
acadrust::types::Color::Index(5),
);
assert_eq!(
app.tabs[i]
.scene
.document
.get_entity(line)
.expect("line")
.common()
.color,
acadrust::types::Color::Index(4),
);
let _ = app.on_line_linetype("ByBlock".to_string());
assert_eq!(
app.tabs[i]
.scene
.document
.get_entity(line)
.expect("line")
.common()
.linetype,
"ByBlock",
);
let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(50));
assert_eq!(
app.tabs[i]
.scene
.document
.get_entity(line)
.expect("line")
.common()
.line_weight,
acadrust::types::LineWeight::Value(50),
);
assert_ne!(
app.tabs[i]
.scene
.document
.get_entity(selected_hatch)
.expect("solid hatch")
.common()
.line_weight,
acadrust::types::LineWeight::Value(50),
);
let _ = app.on_line_linetype_scale(2.5);
assert!(
(app.tabs[i]
.scene
.document
.get_entity(line)
.expect("line")
.common()
.linetype_scale
- 2.5)
.abs()
< f64::EPSILON
);
assert!(
(app.tabs[i]
.scene
.document
.get_entity(selected_hatch)
.expect("solid hatch")
.common()
.linetype_scale
- 1.0)
.abs()
< f64::EPSILON
);
let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(f64::NAN)));
assert_eq!(
app.tabs[i]
.scene
.document
.get_entity(line)
.expect("line")
.common()
.linetype_scale,
2.5
);
let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(1.2000000000000002)));
assert_eq!(
app.tabs[i]
.scene
.document
.get_entity(line)
.expect("line")
.common()
.linetype_scale,
1.2
);
let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.2));
assert!(
(app.tabs[i]
.scene
.document
.get_entity(line)
.expect("line")
.common()
.transparency
.as_percent()
- 0.2)
.abs()
< 0.01
);
assert!(app.tabs[i]
.scene
.document
.get_entity(selected_hatch)
.expect("solid hatch")
.common()
.transparency
.is_by_layer());
let _ = app.on_fill_transparency(acadrust::types::Transparency::from_percent(0.4));
assert!(
(app.tabs[i]
.scene
.document
.get_entity(line)
.expect("line")
.common()
.transparency
.as_percent()
- 0.2)
.abs()
< 0.01
);
let pattern_name = crate::scene::model::hatch_patterns::catalog()
.iter()
.find(|entry| {
matches!(
entry.gpu,
crate::scene::model::hatch_model::HatchPattern::Pattern(_)
)
})
.expect("pattern hatch")
.name
.clone();
let _ = app.update(ga(GraphicAttributesMsg::HatchPattern(pattern_name.clone())));
let selected_pattern = match app.tabs[i]
.scene
.document
.get_entity(selected_hatch)
.expect("pattern hatch")
{
EntityType::Hatch(hatch) => hatch.pattern.name.as_str(),
_ => panic!("fill target is not a hatch"),
};
assert_eq!(selected_pattern, pattern_name);
app.tabs[i].scene.deselect_all();
app.tabs[i].scene.select_entity(selected_hatch, false);
assert_eq!(common(&app, line).linetype, "ByBlock");
let _ = app.on_graphic_attribute(GraphicAttribute::Hatch);
assert_eq!(
palette::current(
&app.tabs[i].scene.document,
&[selected_hatch],
),
GraphicAttribute::Hatch,
);
let _ = app.on_graphic_attribute(GraphicAttribute::Gradient);
assert_eq!(
palette::current(
&app.tabs[i].scene.document,
&[selected_hatch],
),
GraphicAttribute::Gradient,
);
let _ = app.on_line_lineweight(LineWeight::Value(50));
assert_eq!(common(&app, line).line_weight, LineWeight::Value(50));
assert_ne!(common(&app, hatch).line_weight, LineWeight::Value(50));
let _ = app.on_line_linetype_scale(2.5);
assert_eq!(common(&app, line).linetype_scale, 2.5);
assert_eq!(common(&app, hatch).linetype_scale, 1.0);
let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(f64::NAN)));
assert_eq!(common(&app, line).linetype_scale, 2.5);
let _ = app.on_line_transparency(Transparency::from_percent(0.2));
let _ = app.on_fill_transparency(Transparency::from_percent(0.4));
assert!((common(&app, line).transparency.as_percent() - 0.2).abs() < 0.01);
assert!((common(&app, hatch).transparency.as_percent() - 0.4).abs() < 0.01);
let _ = app.on_fill_transparency(Transparency::BY_LAYER);
assert!(common(&app, hatch).transparency.is_by_layer());
assert!((common(&app, line).transparency.as_percent() - 0.2).abs() < 0.01);
}
#[test]
fn choosing_another_fill_closes_the_editors() {
let mut app = fresh();
filled_square(&mut app, GraphicAttribute::Gradient);
app.open_gradient_editor();
assert!(app.graphic_attributes.gradient_editor.is_some());
let _ = app.on_graphic_attribute(GraphicAttribute::Hatch);
assert!(app.graphic_attributes.gradient_editor.is_none());
let _ = app.update(ga(GraphicAttributesMsg::HatchEditorOpen));
assert!(app.graphic_attributes.hatch_editor.is_some());
let _ = app.on_graphic_attribute(GraphicAttribute::Gradient);
assert!(app.graphic_attributes.hatch_editor.is_none());
assert!(app.graphic_attributes.gradient_editor.is_none());
}
#[test]
fn removing_the_fill_erases_it_in_one_undo_step() {
let mut app = fresh();
let i = app.active_tab;
let (_, hatch) = filled_square(&mut app, GraphicAttribute::Solid);
let undo_before = app.tabs[i].history.undo_stack.len();
let _ = app.on_graphic_attribute(GraphicAttribute::None);
assert!(app.tabs[i]
.scene
.document
.get_entity(selected_hatch)
.is_none());
assert!(app.tabs[i].scene.document.get_entity(hatch).is_none());
assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1);
}
+1 -25
View File
@@ -6477,31 +6477,7 @@ impl OpenCADStudio {
Task::none()
}
Message::RibbonLinetypeChanged(lt) => self.on_ribbon_linetype_changed(lt),
Message::RibbonLineweightChanged(lw) => {
let i = self.active_tab;
self.ribbon.close_dropdown();
let handles = self.property_target_handles(i);
if handles.is_empty() {
if self.has_property_selection(i) {
return Task::none();
}
// Persist into the tab's header (CELWEIGHT). #21.
self.tabs[i].scene.document.header.current_line_weight = lw.value();
self.tabs[i].dirty = true;
self.ribbon.active_lineweight = lw;
} else {
// Lineweight is baked into the cached wire geometry —
// re-tessellate so the change shows immediately (issue #231
// class).
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::view::dispatch::apply_line_weight(entity, lw);
}
});
self.ribbon.active_lineweight = lw;
}
Task::none()
}
Message::RibbonLineweightChanged(lw) => self.on_ribbon_lineweight_changed(lw),
Message::RibbonStyleChanged { key, name } => self.on_ribbon_style_changed(key, name),
+33
View File
@@ -5251,6 +5251,39 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"US Survey Feet" => Some(("common", "us-survey-feet")),
"to layout" => Some(("common", "to-layout")),
"{count} objects selected" => Some(("common", "count-objects-selected")),
"Graphic Attributes" => Some(("graphic-attributes", "title")),
"Set all attributes ByLayer" => Some(("graphic-attributes", "set-all-bylayer")),
"Set all attributes ByBlock" => Some(("graphic-attributes", "set-all-byblock")),
"Remove ByLayer / ByBlock" => Some(("graphic-attributes", "remove-bylayer-byblock")),
"Create Layer with active settings" => Some(("graphic-attributes", "create-layer-active-settings")),
"Create fills on the current layer" => Some(("graphic-attributes", "fills-on-current-layer")),
"Edit hatch" => Some(("graphic-attributes", "edit-hatch")),
"Edit gradient" => Some(("graphic-attributes", "edit-gradient")),
"The selected object is open. Close it before creating the fill? [Yes / No] <Yes>" => Some(("graphic-attributes", "close-open-object")),
"{count} selected objects are open. Close them before creating the fill? [Yes / No] <Yes>" => Some(("graphic-attributes", "close-open-objects")),
"Enter a name for the new layer:" => Some(("graphic-attributes", "enter-new-layer-name")),
"Graphic Attributes: enter a valid layer name without <>/\\\":;?*|,=`." => Some(("graphic-attributes", "invalid-layer-name")),
"Graphic Attributes: layer \"{name}\" already exists." => Some(("graphic-attributes", "layer-exists")),
"Graphic Attributes: the layer could not be created." => Some(("graphic-attributes", "layer-not-created")),
"Created layer \"{name}\" with the active Graphic Attributes settings." => Some(("graphic-attributes", "layer-created")),
"Gradient Fill: the fill is on a locked layer." => Some(("graphic-attributes", "gradient-fill-locked")),
"Select one or more closed objects first." => Some(("graphic-attributes", "select-closed-objects")),
"Graphic Attributes: {} object(s) on a locked layer were not changed." => Some(("graphic-attributes", "locked-objects-skipped")),
"Graphic Attributes: no hatch pattern is available." => Some(("graphic-attributes", "no-hatch-pattern")),
"Graphic Attributes: an associated fill is on a locked layer." => Some(("graphic-attributes", "associated-fill-locked")),
"Graphic Attributes: 1 unsupported object was not changed." => Some(("graphic-attributes", "one-unsupported")),
"Graphic Attributes: {skipped} unsupported objects were not changed." => Some(("graphic-attributes", "unsupported-skipped")),
"Fill transparency" => Some(("graphic-attributes", "fill-transparency")),
"Line linetype" => Some(("graphic-attributes", "line-linetype")),
"Line transparency" => Some(("graphic-attributes", "line-transparency")),
"Cyl." => Some(("graphic-attributes", "cylindrical-short")),
"Spher." => Some(("graphic-attributes", "spherical-short")),
"Hemi." => Some(("graphic-attributes", "hemispherical-short")),
"Invert gradient" => Some(("graphic-attributes", "invert-gradient")),
"Tone" => Some(("graphic-attributes", "tone")),
"Reset angle" => Some(("graphic-attributes", "reset-angle")),
"One Color" => Some(("graphic-attributes", "one-color")),
"Two Colors" => Some(("graphic-attributes", "two-colors")),
_ => None,
}
}
+25 -10
View File
@@ -249,16 +249,29 @@ pub(crate) fn filtered_hatch_patterns(
.collect()
}
/// Messages of a hatch pattern picker; the Properties panel and the Graphic
/// Attributes palette each route them to their own state.
pub(crate) struct PatternPickerMessages {
pub search_id: &'static str,
pub on_search: fn(String) -> Message,
pub on_confirm: Message,
pub on_focus: fn(usize) -> Message,
pub on_changed: fn(String) -> Message,
}
pub(crate) fn hatch_pattern_picker_content<'a>(
search_value: &'a str,
focus: usize,
current: &'a str,
search_id: &'static str,
on_search: fn(String) -> Message,
on_confirm: Message,
on_focus: fn(usize) -> Message,
on_changed: fn(String) -> Message,
messages: PatternPickerMessages,
) -> Element<'a, Message> {
let PatternPickerMessages {
search_id,
on_search,
on_confirm,
on_focus,
on_changed,
} = messages;
let search = text_input(t!("Search patterns…").as_ref(), search_value)
.id(iced::widget::Id::new(search_id))
.on_input(on_search)
@@ -1663,11 +1676,13 @@ impl PropertiesPanel {
&self.hatch_pattern_search,
self.hatch_pattern_focus,
current,
"hatch-pattern-search",
Message::PropHatchPatternSearchChanged,
Message::PropHatchPatternConfirm,
Message::PropHatchPatternFocus,
Message::PropHatchPatternChanged,
PatternPickerMessages {
search_id: "hatch-pattern-search",
on_search: Message::PropHatchPatternSearchChanged,
on_confirm: Message::PropHatchPatternConfirm,
on_focus: Message::PropHatchPatternFocus,
on_changed: Message::PropHatchPatternChanged,
},
);
prop_row_widget(
+18 -18
View File
@@ -276,7 +276,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> {
}
})
};
let color_field = |label: &'static str, index, color: AcadColor| {
let color_field = |label: std::borrow::Cow<'static, str>, index, color: AcadColor| {
let (background, _) = crate::ui::properties::acad_color_display(color);
let value = crate::ui::color_select::color_display_name(color);
let picker = button(
@@ -320,11 +320,11 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> {
.width(Fill)
};
let kinds = [
(GradientKind::Linear, "Linear"),
(GradientKind::Cylinder, "Cyl."),
(GradientKind::Spherical, "Spher."),
(GradientKind::Hemispherical, "Hemi."),
(GradientKind::Curved, "Curved"),
(GradientKind::Linear, crate::t!("Linear")),
(GradientKind::Cylinder, crate::t!("Cyl.")),
(GradientKind::Spherical, crate::t!("Spher.")),
(GradientKind::Hemispherical, crate::t!("Hemi.")),
(GradientKind::Curved, crate::t!("Curved")),
];
let mut type_row = row![].spacing(3);
for (kind, label) in kinds {
@@ -357,7 +357,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> {
));
}
let invert_selected = state.inverted && state.kind != GradientKind::Linear;
let invert = button(column![text("⇄").size(18), text("Invert").size(9)].align_x(iced::Center))
let invert = button(column![text("⇄").size(18), text(crate::t!("Invert")).size(9)].align_x(iced::Center))
.on_press_maybe(
(state.kind != GradientKind::Linear).then(|| msg(GradientMsg::InvertToggled)),
)
@@ -378,21 +378,21 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> {
});
type_row = type_row.push(separator).push(tooltip(
invert,
text("Invert gradient").size(10),
text(crate::t!("Invert gradient")).size(10),
tooltip::Position::Bottom,
));
let colors: Element<'_, Message> = if state.color_mode == GradientColorMode::Two {
row![
color_field("Color 1", 1, state.color_1),
color_field("Color 2", 2, state.color_2)
color_field(crate::t!("Color 1"), 1, state.color_1),
color_field(crate::t!("Color 2"), 2, state.color_2)
]
.spacing(6)
.into()
} else {
row![
color_field("Color 1", 1, state.color_1),
color_field(crate::t!("Color 1"), 1, state.color_1),
row![
text("Tone").size(11).width(43),
text(crate::t!("Tone")).size(11).width(43),
slider(0..=100, (state.shade_tint * 100.0) as i32, |v| {
msg(GradientMsg::ShadeTint(v as f32 / 100.0))
})
@@ -408,7 +408,7 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> {
.into()
};
let angle = row![
text("Angle").size(11).width(43),
text(crate::t!("Angle")).size(11).width(43),
text_input("0.0°", &state.angle)
.on_input(|value| msg(GradientMsg::Angle(value)))
.size(11)
@@ -420,19 +420,19 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> {
.padding(3)
.width(24)
.height(crate::ui::ROW_H),
text("Reset angle").size(10),
text(crate::t!("Reset angle")).size(10),
tooltip::Position::Bottom,
),
Space::new().width(Fill),
checkbox(state.centered)
.label("Centered")
.label(crate::t!("Centered"))
.on_toggle(|value| msg(GradientMsg::Centered(value)))
.size(14)
.text_size(11)
]
.spacing(6)
.align_y(iced::Center);
let types = row![text("Type").size(11).width(43), type_row]
let types = row![text(crate::t!("Type")).size(11).width(43), type_row]
.spacing(4)
.align_y(iced::Center);
let actions = container(
@@ -453,8 +453,8 @@ pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> {
let content = column![
container(preview(state)).width(Fill).height(80),
row![
segment("One Color", GradientColorMode::One),
segment("Two Colors", GradientColorMode::Two)
segment(crate::t!("One Color"), GradientColorMode::One),
segment(crate::t!("Two Colors"), GradientColorMode::Two)
]
.spacing(2),
colors,
+38 -33
View File
@@ -71,12 +71,12 @@ impl GraphicAttribute {
impl std::fmt::Display for GraphicAttribute {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Varies => "*VARIES*",
Self::None => "None",
Self::Solid => "Solid",
Self::Hatch => "Hatch",
Self::Gradient => "Gradient",
f.write_str(&match self {
Self::Varies => "*VARIES*".into(),
Self::None => crate::t!("None"),
Self::Solid => crate::t!("Solid"),
Self::Hatch => crate::t!("Hatch"),
Self::Gradient => crate::t!("Gradient"),
})
}
}
@@ -152,7 +152,7 @@ pub struct GraphicAttributesState {
pub fill_on_current_layer: bool,
/// View-side caches; `view` runs after every message, so neither the
/// fill lookup nor the gradient preview is rebuilt unless its input changed.
fill_index: RefCell<Option<((usize, u64), FillIndex)>>,
fill_index: RefCell<Option<(u64, FillIndex)>>,
gradient_preview: RefCell<Option<(HatchGradientPattern, Option<image::Handle>)>>,
}
@@ -166,7 +166,9 @@ impl GraphicAttributesState {
/// The fills and fill kind of `selected`, from an index of associative
/// fills that is rebuilt only when the scene's geometry changes.
pub(crate) fn fills(&self, scene: &Scene, selected: &[Handle]) -> (Vec<Handle>, GraphicAttribute) {
let key = (std::ptr::from_ref(scene) as usize, scene.geometry_epoch);
// Geometry epochs are unique across all scenes, so the epoch alone
// identifies both the tab and the state of its drawing.
let key = scene.geometry_epoch;
let mut cache = self.fill_index.borrow_mut();
if cache.as_ref().is_none_or(|(cached, _)| *cached != key) {
*cache = Some((key, FillIndex::build(&scene.document)));
@@ -219,12 +221,13 @@ impl GraphicAttributesState {
pub fn fill_close_prompt(&self) -> Option<String> {
let count = self.pending_fill_close.as_ref()?.open_boundaries.len();
Some(if count == 1 {
"The selected object is open. Close it before creating the fill? [Yes / No] <Yes>"
.to_string()
crate::t!("The selected object is open. Close it before creating the fill? [Yes / No] <Yes>")
.into_owned()
} else {
format!(
crate::tf!(
"{count} selected objects are open. Close them before creating the fill? [Yes / No] <Yes>"
)
.into_owned()
})
}
}
@@ -634,7 +637,7 @@ fn compact_button_content(label: &'static str) -> Element<'static, Message> {
.into()
}
fn popup_item<'a>(label: &'a str, message: Message) -> Element<'a, Message> {
fn popup_item<'a>(label: std::borrow::Cow<'a, str>, message: Message) -> Element<'a, Message> {
button(text(label).size(11))
.on_press(message)
.style(crate::ui::color_select::list_row_style)
@@ -650,7 +653,7 @@ fn mode_menu<'a>(
menu: Menu,
open_menu: Option<Menu>,
height: f32,
items: Vec<(&'a str, Message)>,
items: Vec<(std::borrow::Cow<'a, str>, Message)>,
) -> Element<'a, Message> {
let open = open_menu == Some(menu);
let more = button(compact_button_content(mode.button_label()))
@@ -738,9 +741,9 @@ fn color_control<'a>(
open_menu,
COMPACT_BUTTON_HEIGHT,
vec![
("ByLayer", msg(changed(AcadColor::ByLayer))),
("ByBlock", msg(changed(AcadColor::ByBlock))),
("Custom...", Message::OpenColorWindow(target, info.color)),
("ByLayer".into(), msg(changed(AcadColor::ByLayer))),
("ByBlock".into(), msg(changed(AcadColor::ByBlock))),
(crate::t!("Custom…"), Message::OpenColorWindow(target, info.color)),
],
);
row![swatch, more].spacing(4).align_y(iced::Center).into()
@@ -777,9 +780,9 @@ fn transparency_control<'a>(
open_menu,
crate::ui::ROW_H,
vec![
("ByLayer", msg(changed(Transparency::BY_LAYER))),
("ByBlock", msg(changed(Transparency::BY_BLOCK))),
("Custom", msg(changed(percent(info.value)))),
("ByLayer".into(), msg(changed(Transparency::BY_LAYER))),
("ByBlock".into(), msg(changed(Transparency::BY_BLOCK))),
(crate::t!("Custom"), msg(changed(percent(info.value)))),
],
);
row![control, more].spacing(4).align_y(iced::Center).into()
@@ -804,7 +807,7 @@ fn header_menu(side: DockSide, open: bool, fill_on_current_layer: bool) -> Eleme
}
let item = |label: &'static str, message| {
button(text(label).size(11))
button(text(crate::t!(label)).size(11))
.on_press(msg(message))
.style(crate::ui::color_select::list_row_style)
.width(Fill)
@@ -817,7 +820,7 @@ fn header_menu(side: DockSide, open: bool, fill_on_current_layer: bool) -> Eleme
item("Create Layer with active settings", GraphicAttributesMsg::CreateLayer),
container(
iced::widget::checkbox(fill_on_current_layer)
.label("Create fills on the current layer")
.label(crate::t!("Create fills on the current layer"))
.on_toggle(|_| msg(GraphicAttributesMsg::ToggleFillOnCurrentLayer))
.size(12)
.text_size(11),
@@ -952,7 +955,7 @@ fn preview_row<'a>(
preview: Element<'a, Message>,
editor_open: bool,
toggle: GraphicAttributesMsg,
tooltip_label: &'static str,
tooltip_label: std::borrow::Cow<'static, str>,
flyout: Option<(Element<'a, Message>, f32, Option<f32>)>,
side: DockSide,
) -> Element<'a, Message> {
@@ -1054,8 +1057,8 @@ fn linetype_row<'a>(
open_menu,
COMPACT_BUTTON_HEIGHT,
vec![
("ByLayer", msg(GraphicAttributesMsg::LineLinetype("ByLayer".into()))),
("ByBlock", msg(GraphicAttributesMsg::LineLinetype("ByBlock".into()))),
("ByLayer".into(), msg(GraphicAttributesMsg::LineLinetype("ByLayer".into()))),
("ByBlock".into(), msg(GraphicAttributesMsg::LineLinetype("ByBlock".into()))),
],
);
row![crate::ui::wide_menu::wide_menu(combo, 220.0), more]
@@ -1166,8 +1169,8 @@ fn lineweight_field<'a>(
open_menu,
COMPACT_BUTTON_HEIGHT,
vec![
("ByLayer", msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByLayer))),
("ByBlock", msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByBlock))),
("ByLayer".into(), msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByLayer))),
("ByBlock".into(), msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByBlock))),
],
);
row![
@@ -1324,11 +1327,13 @@ pub fn view<'a>(
&editor.search,
editor.focus,
hatch.map_or("", |hatch| hatch.pattern.name.as_str()),
"graphic-hatch-pattern-search",
|search| msg(GraphicAttributesMsg::HatchPatternSearch(search)),
msg(GraphicAttributesMsg::HatchPatternConfirm),
|index| msg(GraphicAttributesMsg::HatchPatternFocus(index)),
|name| msg(GraphicAttributesMsg::HatchPattern(name)),
crate::ui::properties::PatternPickerMessages {
search_id: "graphic-hatch-pattern-search",
on_search: |search| msg(GraphicAttributesMsg::HatchPatternSearch(search)),
on_confirm: msg(GraphicAttributesMsg::HatchPatternConfirm),
on_focus: |index| msg(GraphicAttributesMsg::HatchPatternFocus(index)),
on_changed: |name| msg(GraphicAttributesMsg::HatchPattern(name)),
},
);
let popup = container(picker)
.padding(1)
@@ -1352,7 +1357,7 @@ pub fn view<'a>(
} else {
GraphicAttributesMsg::HatchEditorOpen
},
"Edit hatch",
crate::t!("Edit hatch"),
flyout,
side,
));
@@ -1390,7 +1395,7 @@ pub fn view<'a>(
} else {
GradientMsg::Open
}),
"Edit gradient",
crate::t!("Edit gradient"),
flyout,
side,
));