Merge main into codex/graphic-attributes-cleanup

Resolve conflicts from main's rustfmt pass by taking main's versions of
command.rs, style.rs, view/mod.rs, scene/mod.rs, ui/window/mod.rs and
ui/properties.rs and re-applying this branch's Graphic Attributes changes.
Add the Graphic Attributes strings to the new lo-LA catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1QnME6WJbD5iGFae1eeCb
This commit is contained in:
Claude committed 2026-10-11 19:09:37 +00:00
commit 44f8f76ab3
596 files changed
+69069 -34120

No files matched your search

+416 -174
View File
@@ -24,6 +24,7 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool {
| Message::CommandLineArrowProbe { .. }
| Message::CommandLineArrowResolved { .. }
| Message::DynTabNext
| Message::DynTabPrev
| Message::MTextCaretMove(_)
| Message::DeleteSelected
| Message::ToggleSnapEnabled
@@ -90,9 +91,9 @@ fn reorder_insertion_index(from: usize, to: usize, after: bool, len: usize) -> O
(insertion != from).then_some(insertion)
}
mod blocks_palette;
mod command;
mod context_menu;
mod blocks_palette;
mod dialog;
mod dynamic;
mod file;
@@ -352,7 +353,8 @@ impl OpenCADStudio {
2 => "ATTMODE2",
_ => "ATTMODE1",
};
self.ribbon.set_dropdown_current("ATTMODE_DROPDOWN", attmode);
self.ribbon
.set_dropdown_current("ATTMODE_DROPDOWN", attmode);
}
fn update_message(&mut self, msg: Message) -> Task<Message> {
@@ -382,8 +384,7 @@ impl OpenCADStudio {
{
// Esc backs out of a pending ALIASEDIT draft first, mirroring
// the shortcut editor's capture cancel; the next Esc closes.
if self.active_modal == Some(super::ModalKind::Aliases) && self.alias_pending_add
{
if self.active_modal == Some(super::ModalKind::Aliases) && self.alias_pending_add {
return self.update(Message::AliasEditorDraftCancel);
}
if self.active_modal == Some(super::ModalKind::BlockDefinition) {
@@ -445,9 +446,12 @@ impl OpenCADStudio {
// a mouse move included — without allocating.
#[cfg(not(target_arch = "wasm32"))]
let active_command = |app: &Self| -> Option<(u64, Option<&'static str>)> {
app.tabs
.get(app.active_tab)
.map(|tab| (tab.id, tab.active_cmd.as_ref().map(|command| command.name())))
app.tabs.get(app.active_tab).map(|tab| {
(
tab.id,
tab.active_cmd.as_ref().map(|command| command.name()),
)
})
};
#[cfg(not(target_arch = "wasm32"))]
let command_before = active_command(self);
@@ -544,7 +548,9 @@ impl OpenCADStudio {
}
}
if let Some(started) = perf_started {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
let elapsed_us = started.elapsed().as_micros() as u64;
crate::perf::note_update_time(elapsed_us);
let elapsed_ms = elapsed_us as f64 / 1000.0;
if elapsed_ms >= 5.0 {
crate::perf_record!("[perf] update {:>7.1}ms message={perf_label}", elapsed_ms,);
}
@@ -575,9 +581,8 @@ impl OpenCADStudio {
Message::SpaceMouseWake => self.on_spacemouse_wake(),
Message::TrackpadPinch(magnification) => self.on_pinch_zoom(magnification),
Message::SpaceMouseFrame(time) => {
self.spacemouse.frame(
time.saturating_duration_since(self.start).as_secs_f64() * 1000.,
);
self.spacemouse
.frame(time.saturating_duration_since(self.start).as_secs_f64() * 1000.);
Task::none()
}
Message::SpaceMouseFocus(id, focused) => {
@@ -629,9 +634,8 @@ impl OpenCADStudio {
if response["ok"] == false {
let code = response["code"].as_str().unwrap_or("failed");
let detail = response["error"].as_str().unwrap_or("");
self.command_line.push_error(&format!(
"automation {op}: {code} — {detail}"
));
self.command_line
.push_error(&format!("automation {op}: {code} — {detail}"));
} else if matches!(
response["status"].as_str(),
Some("completed" | "cancelled" | "waiting_input")
@@ -977,7 +981,8 @@ impl OpenCADStudio {
.scene
.selection
.borrow_mut()
.menu.open_at = None;
.menu
.open_at = None;
// Same guard as typed MTP/M2P: point step, not entity pick.
let i = self.active_tab;
let allowed = self.tabs[i].active_cmd.as_ref().is_some_and(|c| {
@@ -1237,19 +1242,23 @@ impl OpenCADStudio {
Task::none()
}
Message::ImageEmbedPick => {
Task::perform(crate::io::pick_embedded_image_file(), Message::ImageEmbedPickResult)
}
Message::ImageEmbedPick => Task::perform(
crate::io::pick_embedded_image_file(),
Message::ImageEmbedPickResult,
),
Message::ImageEmbedPickResult(Ok(image)) => {
use crate::command::CadCommand;
use crate::modules::draw::draw::raster_image::ImageCommand;
self.command_line.push_output(crate::tf!(
"IMAGEEMBED \"{name}\": {w}×{h} px (embedded)",
name = image.name.as_str(),
w = image.pixel_width,
h = image.pixel_height,
).as_ref());
self.command_line.push_output(
crate::tf!(
"IMAGEEMBED \"{name}\": {w}×{h} px (embedded)",
name = image.name.as_str(),
w = image.pixel_width,
h = image.pixel_height,
)
.as_ref(),
);
let cmd = ImageCommand::new_embedded(image);
let i = self.active_tab;
self.command_line.push_info(&cmd.prompt());
@@ -1336,10 +1345,13 @@ impl OpenCADStudio {
.insert(crate::io::font_repo::font_key(name));
}
for (name, path) in &pairs {
self.command_line.push_output(crate::tf!(
"FONT Downloaded {name} → {path}",
path = path.display()
).as_ref());
self.command_line.push_output(
crate::tf!(
"FONT Downloaded {name} → {path}",
path = path.display()
)
.as_ref(),
);
}
if !unavailable.is_empty() {
self.command_line.push_info(crate::tf!(
@@ -1355,14 +1367,16 @@ impl OpenCADStudio {
if let Some(path) = path {
return Task::done(Message::OpenExternal(path));
}
self.command_line.push_info(crate::t!(
"Save and reopen the drawing to apply the new fonts."
).as_ref());
self.command_line.push_info(
crate::t!("Save and reopen the drawing to apply the new fonts.")
.as_ref(),
);
}
Err(e) => {
// Keep the prompt open: the user can correct a custom
// source or retry a transient network failure.
self.command_line.push_error(crate::tf!("Font download failed: {e}").as_ref());
self.command_line
.push_error(crate::tf!("Font download failed: {e}").as_ref());
}
}
Task::none()
@@ -1900,9 +1914,9 @@ impl OpenCADStudio {
self.refresh_layer_panel();
self.sync_ribbon_styles();
}
Err(error) => self.command_line.push_error(
error["error"].as_str().unwrap_or("template"),
),
Err(error) => self
.command_line
.push_error(error["error"].as_str().unwrap_or("template")),
}
}
Task::none()
@@ -2168,16 +2182,21 @@ impl OpenCADStudio {
let i = self.active_tab;
// Capture prompt/options while holding cmd borrow, then release before
// borrowing command_line to satisfy borrow checker.
let (should_update, opts, prompt) =
let (should_update, opts, prompt, suggestions) =
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
if cmd.on_live_input(&live_input) {
(true, cmd.options(), cmd.prompt())
let changed = cmd.on_live_input(&live_input);
let suggestions = cmd.text_suggestions(&live_input);
if changed {
(true, cmd.options(), cmd.prompt(), suggestions)
} else {
(false, Vec::new(), String::new())
(false, Vec::new(), String::new(), suggestions)
}
} else {
(false, Vec::new(), String::new())
(false, Vec::new(), String::new(), Vec::new())
};
self.command_line.set_contextual_suggestions(suggestions);
if should_update {
self.command_line.set_step_options(opts);
// Update pinned prompt text live without pushing new history entry
@@ -2206,6 +2225,12 @@ impl OpenCADStudio {
}
Message::DynTabNext => {
if self.command_line.has_contextual_suggestions()
&& self.command_line.autocomplete_next()
{
return Task::none();
}
let i = self.active_tab;
let n = self.tabs[i].dyn_fields.len();
if n > 0 {
@@ -2217,6 +2242,13 @@ impl OpenCADStudio {
self.focus_cmd_input()
}
Message::DynTabPrev => {
if self.command_line.has_contextual_suggestions() {
self.command_line.autocomplete_prev();
}
self.focus_cmd_input()
}
Message::SplitModelViewport(horizontal) => {
let i = self.active_tab;
self.tabs[i].scene.split_active_pane(horizontal);
@@ -2253,7 +2285,8 @@ impl OpenCADStudio {
}
let i = self.active_tab;
if !self.command_line.history_navigation_active()
&& self.tabs[i].active_cmd.is_none()
&& (self.tabs[i].active_cmd.is_none()
|| self.command_line.has_contextual_suggestions())
&& self.command_line.autocomplete_prev()
{
return Task::none();
@@ -2279,7 +2312,8 @@ impl OpenCADStudio {
}
let i = self.active_tab;
if !self.command_line.history_navigation_active()
&& self.tabs[i].active_cmd.is_none()
&& (self.tabs[i].active_cmd.is_none()
|| self.command_line.has_contextual_suggestions())
&& self.command_line.autocomplete_next()
{
return Task::none();
@@ -2480,11 +2514,20 @@ impl OpenCADStudio {
}
}
Message::CommandSuggestionPick(cmd) => {
Message::CommandSuggestionPick(value) => {
self.command_line.close_history();
if self.tabs[self.active_tab].active_cmd.is_some()
&& self.command_line.has_contextual_suggestions()
{
self.command_line.input = value;
self.command_line.autocomplete_cursor = None;
return self.on_command_submit();
}
self.command_line.input.clear();
self.command_line.autocomplete_cursor = None;
self.command_line.close_history();
self.dispatch_command(&cmd)
self.dispatch_command(&value)
}
Message::CommandOptionPick(kw) => {
@@ -2586,7 +2629,8 @@ impl OpenCADStudio {
use crate::app::config::DockSide;
use crate::ui::dock::PanelId;
if self.dock.location(PanelId::ExternalReferences).is_none() {
self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX);
self.dock
.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX);
}
self.dock_expanded = Some(PanelId::ExternalReferences);
self.refresh_xref_manager();
@@ -2597,7 +2641,7 @@ impl OpenCADStudio {
self.refresh_xref_manager();
Task::none()
}
Message::XrefManagerSelect(index) => {
Message::XrefManagerSelect(id) => {
use crate::ui::window::xref_manager::SelectExtend;
// Normal GUI list behavior off the globally tracked
// modifiers (same source as the layer list): plain click
@@ -2609,12 +2653,12 @@ impl OpenCADStudio {
} else {
SelectExtend::Single
};
self.xref_manager.click_select(index, extend);
self.xref_manager.click_select(&id, extend);
Task::none()
}
Message::XrefRowRightClick(index) => {
Message::XrefRowRightClick(id) => {
self.xref_manager.row_change_path_open = false;
self.xref_manager.right_click_select(index);
self.xref_manager.right_click_select(&id);
Task::none()
}
Message::XrefManagerToggleTree => {
@@ -2724,16 +2768,13 @@ impl OpenCADStudio {
};
let new_raw = path.to_string_lossy().into_owned();
self.push_undo_snapshot(i, "XREF-PATH");
match crate::io::xref::set_ref_path(
&mut self.tabs[i].scene.document,
key,
&new_raw,
) {
match crate::io::xref::set_ref_path(&mut self.tabs[i].scene.document, key, &new_raw)
{
Ok(name) => {
self.command_line.push_output(crate::tf!(
"XREF: Path set for \"{}\" — Reload to apply.",
name
).as_ref());
self.command_line.push_output(
crate::tf!("XREF: Path set for \"{}\" — Reload to apply.", name)
.as_ref(),
);
self.post_ref_op(i);
}
Err(msg) => self.command_line.push_error(msg.as_str()),
@@ -2743,7 +2784,8 @@ impl OpenCADStudio {
}
Message::XrefPathPickResult(Err(e)) => {
if e != "Cancelled" {
self.command_line.push_error(crate::tf!("XREF: {e}").as_ref());
self.command_line
.push_error(crate::tf!("XREF: {e}").as_ref());
}
Task::none()
}
@@ -2759,62 +2801,76 @@ impl OpenCADStudio {
self.xref_manager_op(op);
Task::none()
}
Message::XrefRowOp(index, op) => {
Message::XrefRowOp(id, op) => {
// Open is not a palette op — it navigates to the file.
if op == crate::ui::window::xref_manager::XrefPaletteOp::Open {
self.xref_manager.right_click_select(index);
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
if let Some(entry) = self.xref_manager.entries.get(index) {
if let Some(found) = entry.found_at.clone() {
let is_dwg = found.to_ascii_lowercase().ends_with(".dwg")
|| found.to_ascii_lowercase().ends_with(".dxf");
self.command_line.push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref());
if is_dwg {
return self.update(Message::OpenRecent(std::path::PathBuf::from(found)));
if let Some(index) = self.xref_manager.row_index(&id) {
if let Some(entry) = self.xref_manager.entries.get(index) {
if let Some(found) = entry.found_at.clone() {
let is_dwg = found.to_ascii_lowercase().ends_with(".dwg")
|| found.to_ascii_lowercase().ends_with(".dxf");
self.command_line.push_output(
crate::tf!("XOPEN: opening \"{}\".", found).as_ref(),
);
if is_dwg {
return self.update(Message::OpenRecent(
std::path::PathBuf::from(found),
));
} else {
#[cfg(not(target_arch = "wasm32"))]
let _ = open::that_detached(&found);
}
} else {
#[cfg(not(target_arch = "wasm32"))]
let _ = open::that_detached(&found);
self.command_line.push_error(
crate::t!("File not found — check the saved path.").as_ref(),
);
}
} else {
self.command_line.push_error(
crate::t!("File not found — check the saved path.").as_ref(),
);
}
}
return Task::none();
}
// Attach prompts the file dialog to pick a drawing to attach.
if matches!(op, crate::ui::window::xref_manager::XrefPaletteOp::Attach) {
self.xref_manager.right_click_select(index);
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
self.command_line.push_output(crate::t!("XATTACH").as_ref());
return self.update(Message::XAttachPick);
}
// Row-scoped op: select the row first, then run the op.
self.xref_manager.right_click_select(index);
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
self.xref_manager_op(op);
Task::none()
}
Message::XrefRowPathPick(index) => {
self.xref_manager.right_click_select(index);
Message::XrefRowPathPick(id) => {
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
self.update(Message::XrefPathPick)
}
Message::XrefRowFindReplacePrompt(index) => {
self.xref_manager.right_click_select(index);
Message::XrefRowFindReplacePrompt(id) => {
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
let prefill = if let Some(entry) = self.xref_manager.entries.get(index) {
let saved = &entry.saved_path;
if let Some(parent) = std::path::Path::new(saved).parent().and_then(|p| p.to_str()) {
if !parent.is_empty() {
format!("XREF Path Find \"{}\" ", parent)
} else {
"XREF Path Find ".to_string()
}
} else {
"XREF Path Find ".to_string()
}
let prefill = if let Some(index) = self.xref_manager.row_index(&id) {
self.xref_manager.entries.get(index).map_or_else(
|| "XREF Path Find ".to_string(),
|entry| {
let saved = &entry.saved_path;
if let Some(parent) = std::path::Path::new(saved)
.parent()
.and_then(|p| p.to_str())
{
if !parent.is_empty() {
format!("XREF Path Find \"{}\" ", parent)
} else {
"XREF Path Find ".to_string()
}
} else {
"XREF Path Find ".to_string()
}
},
)
} else {
"XREF Path Find ".to_string()
};
@@ -3148,20 +3204,15 @@ impl OpenCADStudio {
super::LayerStateProperty::Frozen => codec::LayerStateMask::FROZEN,
super::LayerStateProperty::Locked => codec::LayerStateMask::LOCKED,
super::LayerStateProperty::Plot => codec::LayerStateMask::PLOT,
super::LayerStateProperty::NewViewport => {
codec::LayerStateMask::NEW_VIEWPORT
}
super::LayerStateProperty::NewViewport => codec::LayerStateMask::NEW_VIEWPORT,
super::LayerStateProperty::Color => codec::LayerStateMask::COLOR,
super::LayerStateProperty::LineType => codec::LayerStateMask::LINE_TYPE,
super::LayerStateProperty::LineWeight => codec::LayerStateMask::LINE_WEIGHT,
super::LayerStateProperty::PlotStyle => codec::LayerStateMask::PLOT_STYLE,
super::LayerStateProperty::Transparency => {
codec::LayerStateMask::TRANSPARENCY
}
super::LayerStateProperty::Transparency => codec::LayerStateMask::TRANSPARENCY,
};
if let Some(state) = self.layer_state_edit_draft.as_mut() {
state.mask =
codec::LayerStateMask::from_bits(state.mask.bits() ^ flag.bits());
state.mask = codec::LayerStateMask::from_bits(state.mask.bits() ^ flag.bits());
}
Task::none()
}
@@ -3574,10 +3625,14 @@ impl OpenCADStudio {
panel.selected_multi.push(idx);
}
} else if shift {
// Shift-click selects the range from the anchor to here.
// Shift-click selects the range from the anchor to here —
// visible rows only, so filtered-out rows between them
// cannot join bulk edits/deletes (#50).
let anchor = panel.selected.unwrap_or(idx);
let (lo, hi) = (anchor.min(idx), anchor.max(idx));
panel.selected_multi = (lo..=hi).collect();
let visible: std::collections::HashSet<usize> =
panel.visible_indices().into_iter().collect();
panel.selected_multi = (lo..=hi).filter(|r| visible.contains(r)).collect();
} else if !panel.selected_multi.contains(&idx) {
// Plain click collapses to this row — but NOT when it is
// already part of a multi-selection, so clicking a property
@@ -3596,10 +3651,8 @@ impl OpenCADStudio {
};
self.tabs[i].layers.selected = Some(idx);
self.tabs[i].layers.selected_multi = vec![idx];
if let Some(layer) = self.tabs[i].layers.layers.get(idx) {
self.tabs[i].layers.edit_buf = layer.name.clone();
}
self.tabs[i].layers.editing = Some(idx);
self.tabs[i].layers.edit_buf = name.clone();
self.tabs[i].layers.editing = Some(name);
Task::none()
}
@@ -3617,11 +3670,11 @@ impl OpenCADStudio {
return Task::none();
};
let panel = &mut self.tabs[i].layers;
if panel.color_picker_row == Some(idx) {
if panel.color_picker_row.as_deref() == Some(name.as_str()) {
panel.color_picker_row = None;
panel.color_full_palette = false;
} else {
panel.color_picker_row = Some(idx);
panel.color_picker_row = Some(name);
panel.color_full_palette = false;
panel.selected = Some(idx);
// Opening the swatch on a row outside the current
@@ -3734,23 +3787,25 @@ impl OpenCADStudio {
// Apply the edited transparency to every selected layer (#236).
if let Some(v) = val {
let targets = self.layer_row_action_targets(i, idx);
for name in &targets {
if let Some(layer) = self.tabs[i].scene.document.layers.get_mut(name) {
layer.transparency =
codec::types::Transparency::from_percent(v as f64 / 100.0);
}
if let Some(pl) = self.tabs[i]
.layers
.layers
.iter_mut()
.find(|l| &l.name == name)
{
pl.transparency = v;
}
}
if !targets.is_empty() {
let undo = self.begin_layer_undo(i, "LAYER TRANSPARENCY", &targets);
for name in &targets {
if let Some(layer) = self.tabs[i].scene.document.layers.get_mut(name) {
layer.transparency =
codec::types::Transparency::from_percent(v as f64 / 100.0);
}
if let Some(pl) = self.tabs[i]
.layers
.layers
.iter_mut()
.find(|l| &l.name == name)
{
pl.transparency = v;
}
}
self.tabs[i].scene.invalidate_layer_dependencies(&targets);
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
}
}
Task::none()
@@ -3779,7 +3834,8 @@ impl OpenCADStudio {
.scene
.selection
.borrow_mut()
.input.last_move_pos = Some(p);
.input
.last_move_pos = Some(p);
return Task::none();
}
self.focus_model_pane(idx);
@@ -3878,7 +3934,8 @@ impl OpenCADStudio {
let was_click = !sel.input.right_dragging;
// How long the button was held, for the time-sensitive mode.
let held_ms = sel
.input.right_press_time
.input
.right_press_time
.map_or(0, |t| t.elapsed().as_millis() as i32);
sel.input.right_down = false;
sel.input.right_press_pos = None;
@@ -4073,7 +4130,8 @@ impl OpenCADStudio {
.scene
.selection
.borrow()
.input.last_move_pos
.input
.last_move_pos
.unwrap_or(self.cursor_pos);
self.update_grip_hover(i, p);
Task::none()
@@ -4479,7 +4537,9 @@ impl OpenCADStudio {
continue;
};
let current = (
crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(),
crate::scene::pe_url_of(entity)
.unwrap_or_default()
.to_owned(),
crate::scene::pe_url_description_of(entity)
.unwrap_or_default()
.to_owned(),
@@ -4584,28 +4644,36 @@ impl OpenCADStudio {
}
Message::AnnoObjectScaleToggle(name) => {
let i = self.active_tab;
if let Some(entity) = self.anno_object_scale_target {
if self.tabs[i].scene.is_layer_locked(entity) {
return Task::none();
}
if let Some(sh) = self.tabs[i].scene.scale_handle_ensuring(&name) {
self.push_undo_snapshot(i, "OBJECTSCALE");
let doc = &mut self.tabs[i].scene.document;
let is_member =
crate::scene::annotative::object_scale_memberships(doc, entity)
.iter()
.any(|(_, h)| *h == sh);
if is_member {
crate::scene::annotative::remove_annotation_context_for_scale(
doc, entity, sh,
);
} else {
crate::scene::annotative::create_annotation_context(doc, entity, sh);
}
self.tabs[i].dirty = true;
self.invalidate_property_targets(i, &[entity]);
self.refresh_properties();
let Some(entity) = self.anno_object_scale_target else {
self.command_line.push_info(
crate::t!("Select one object first, then run OBJECTSCALE.").as_ref(),
);
return Task::none();
};
if self.tabs[i].scene.is_layer_locked(entity) {
self.command_line
.push_info(crate::t!("The object is on a locked layer.").as_ref());
return Task::none();
}
if let Some(sh) = self.tabs[i].scene.scale_handle_ensuring(&name) {
self.push_undo_snapshot(i, "OBJECTSCALE");
let doc = &mut self.tabs[i].scene.document;
let is_member = crate::scene::annotative::object_scale_memberships(doc, entity)
.iter()
.any(|(_, h)| *h == sh);
if is_member {
crate::scene::annotative::remove_annotation_context_for_scale(
doc, entity, sh,
);
} else {
crate::scene::annotative::create_annotation_context(doc, entity, sh);
}
self.tabs[i].dirty = true;
self.invalidate_property_targets(i, &[entity]);
self.refresh_properties();
} else {
self.command_line
.push_info(crate::tf!("Unknown annotation scale '{name}'.").as_ref());
}
Task::none()
}
@@ -4931,7 +4999,9 @@ impl OpenCADStudio {
self.tabs[i].scene.bump_geometry();
self.refresh_properties();
crate::entities::common::set_unit_context(
crate::entities::common::UnitContext::from_header(&self.tabs[i].scene.document.header),
crate::entities::common::UnitContext::from_header(
&self.tabs[i].scene.document.header,
),
);
Task::none()
}
@@ -5090,12 +5160,14 @@ impl OpenCADStudio {
};
let name = state.name.trim().to_string();
if name.is_empty() {
state.error_message = Some(crate::t!("Block name cannot be empty.").into_owned());
state.error_message =
Some(crate::t!("Block name cannot be empty.").into_owned());
return Task::none();
}
if let Some(_ch) = state.invalid_name_char() {
state.error_message = Some(
crate::t!("Block name cannot contain: \\ / : * ? \" < > | = `").into_owned(),
crate::t!("Block name cannot contain: \\ / : * ? \" < > | = `")
.into_owned(),
);
return Task::none();
}
@@ -5106,13 +5178,21 @@ impl OpenCADStudio {
}
if !state.objects_specify_onscreen && state.selected_handles.is_empty() {
state.error_message = Some(
crate::t!("No objects selected. You must select objects to define a block.")
.into_owned(),
crate::t!(
"No objects selected. You must select objects to define a block."
)
.into_owned(),
);
return Task::none();
}
let i = self.active_tab;
if self.tabs[i].scene.document.block_records.get(&name).is_some() {
if self.tabs[i]
.scene
.document
.block_records
.get(&name)
.is_some()
{
state.confirm_redefine = Some(name);
return Task::none();
}
@@ -5138,12 +5218,16 @@ impl OpenCADStudio {
let current_path = std::path::Path::new(&state.file_path);
let file_stem = current_path.file_stem().and_then(|s| s.to_str());
let is_default_or_block = file_stem == Some("new_block")
|| state.existing_blocks.iter().any(|b| Some(b.as_str()) == file_stem);
|| state
.existing_blocks
.iter()
.any(|b| Some(b.as_str()) == file_stem);
if is_default_or_block {
let new_file_name = format!("{}.dwg", trimmed);
if let Some(parent) = current_path.parent() {
if !parent.as_os_str().is_empty() {
state.file_path = parent.join(new_file_name).to_string_lossy().to_string();
state.file_path =
parent.join(new_file_name).to_string_lossy().to_string();
} else {
state.file_path = new_file_name;
}
@@ -5264,12 +5348,19 @@ impl OpenCADStudio {
WblockSourceMode::Block => {
let name = state.block_name.trim();
if name.is_empty() {
state.error_message =
Some(crate::t!("Please select or enter a block name.").into_owned());
state.error_message = Some(
crate::t!("Please select or enter a block name.").into_owned(),
);
return Task::none();
}
let i = self.active_tab;
if self.tabs[i].scene.document.block_records.get(name).is_none() {
if self.tabs[i]
.scene
.document
.block_records
.get(name)
.is_none()
{
state.error_message = Some(
crate::tf!("Block \"{}\" does not exist in drawing.", name)
.into_owned(),
@@ -5394,10 +5485,7 @@ impl OpenCADStudio {
}
WblockSourceMode::Objects => {
crate::modules::insert::wblock::extract_entities_to_doc_with_base(
&document,
&handles,
base_point,
unit,
&document, &handles, base_point, unit,
)
.map_err(|e| e.to_string())?
}
@@ -5456,7 +5544,9 @@ impl OpenCADStudio {
self.tabs[i].scene.bump_geometry();
self.refresh_properties();
crate::entities::common::set_unit_context(
crate::entities::common::UnitContext::from_header(&self.tabs[i].scene.document.header),
crate::entities::common::UnitContext::from_header(
&self.tabs[i].scene.document.header,
),
);
let label = crate::modules::draw::units::linear_format_label(code);
self.command_line
@@ -5786,8 +5876,7 @@ impl OpenCADStudio {
Task::none()
}
Message::AutoConstrainReset => {
self.auto_constrain_settings =
super::settings::AutoConstrainSettings::default();
self.auto_constrain_settings = super::settings::AutoConstrainSettings::default();
self.auto_constrain_selected_row = 0;
self.auto_constrain_distance_input =
format!("{}", self.auto_constrain_settings.distance_tolerance);
@@ -5826,8 +5915,7 @@ impl OpenCADStudio {
self.auto_constrain_settings.distance_tolerance = distance;
self.auto_constrain_settings.angle_tolerance_deg = angle;
self.auto_constrain_settings.sanitize();
self.auto_constrain_saved =
Some(self.auto_constrain_settings.clone());
self.auto_constrain_saved = Some(self.auto_constrain_settings.clone());
self.persist_settings_if_changed();
if matches!(action, Message::AutoConstrainOk) {
self.close_active_modal();
@@ -6871,11 +6959,12 @@ impl OpenCADStudio {
};
let Some((sx, sy, sz)) = scales else { return };
let eq = (sx - sy).abs() < 1e-12 && (sx - sz).abs() < 1e-12;
let checked = eq && !app.props_asym_scale.contains(&handle.value());
let checked =
eq && !app.tabs[i].props_asym_scale.contains(&handle.value());
if checked {
app.props_asym_scale.insert(handle.value());
app.tabs[i].props_asym_scale.insert(handle.value());
} else {
app.props_asym_scale.remove(&handle.value());
app.tabs[i].props_asym_scale.remove(&handle.value());
if let Some(codec::EntityType::Insert(ins)) =
app.tabs[i].scene.document.get_entity_mut(handle)
{
@@ -8405,6 +8494,18 @@ impl OpenCADStudio {
Task::none()
}
Message::TouchpadPanChanged(enabled) => {
self.touchpad_pan = enabled;
self.persist_settings_if_changed();
Task::none()
}
Message::UiScaleChanged(percent) => {
self.ui_scale_percent = crate::app::settings::clamp_ui_scale(percent);
self.persist_settings_if_changed();
Task::none()
}
Message::ZoomFactorChanged(factor) => {
self.zoom_factor = crate::app::settings::clamp_zoom_factor(factor);
// The field beside the slider reads the same value, so the
@@ -8685,7 +8786,9 @@ impl OpenCADStudio {
Message::CloseModal => {
// The Invalid Area dialog's ✕ undoes the boundary change, as its Cancel does.
if self.active_modal == Some(super::ModalKind::CountInvalidArea) {
return self.update(Message::Count(crate::ui::window::count_palette::CountMsg::InvalidUndo));
return self.update(Message::Count(
crate::ui::window::count_palette::CountMsg::InvalidUndo,
));
}
if self.active_modal == Some(super::ModalKind::SheetSet) {
self.close_sheet_set_dialog();
@@ -8702,7 +8805,11 @@ impl OpenCADStudio {
self.attdef_dialog = None;
let editing = self.attdef_edit.take().is_some();
self.close_active_modal();
return if editing { self.post_editor_closed(false) } else { Task::none() };
return if editing {
self.post_editor_closed(false)
} else {
Task::none()
};
}
// Cancelling the section settings drops the waiting extraction.
if self.active_modal == Some(super::ModalKind::PcSection) {
@@ -11122,6 +11229,136 @@ mod prop_pointer_tests {
}
}
#[cfg(test)]
mod anno_scale_toggle_tests {
// #59: silent no-op toggles must explain themselves on the command line.
use super::Message;
use crate::app::OpenCADStudio;
fn app_with_target() -> (OpenCADStudio, usize) {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let mut line = codec::entities::Line::new();
line.start = codec::types::Vector3::ZERO;
line.end = codec::types::Vector3::new(1.0, 0.0, 0.0);
let h = app
.commit_entity_handle(codec::EntityType::Line(line))
.expect("line commits");
app.anno_object_scale_target = Some(h);
(app, i)
}
#[test]
fn locked_layer_toggle_explains_itself() {
let (mut app, i) = app_with_target();
app.tabs[i]
.scene
.document
.layers
.get_mut("0")
.expect("layer 0 exists")
.flags
.locked = true;
let before = app.command_line.history.len();
let _ = app.update(Message::AnnoObjectScaleToggle("1:50".to_string()));
let new: Vec<String> = app.command_line.history[before..]
.iter()
.map(|e| e.text.clone())
.collect();
assert!(
new.iter().any(|t| t.to_lowercase().contains("locked")),
"locked-layer toggle must say so, got {new:?}"
);
}
#[test]
fn unknown_scale_toggle_explains_itself() {
let (mut app, _i) = app_with_target();
let before = app.command_line.history.len();
let _ = app.update(Message::AnnoObjectScaleToggle("NOPE".to_string()));
let new: Vec<String> = app.command_line.history[before..]
.iter()
.map(|e| e.text.clone())
.collect();
assert!(
!new.is_empty(),
"unknown-scale toggle must say something, got silence"
);
}
#[test]
fn missing_target_toggle_explains_itself() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.anno_object_scale_target = None;
let before = app.command_line.history.len();
let _ = app.update(Message::AnnoObjectScaleToggle("1:50".to_string()));
let new: Vec<String> = app.command_line.history[before..]
.iter()
.map(|e| e.text.clone())
.collect();
assert!(
!new.is_empty(),
"target-less toggle must say something, got silence"
);
}
}
#[cfg(test)]
mod dock_visibility_tests {
// #57: one visibility predicate for edge rendering and drag math. Pins
// the full 8-panel table so a newly added panel cannot silently diverge
// (behavior-preserving dedup: passes before and after by construction).
use crate::app::OpenCADStudio;
use crate::ui::dock::PanelId;
const ALL: [PanelId; 8] = [
PanelId::Properties,
PanelId::BlockPalette,
PanelId::ExternalReferences,
PanelId::Browser,
PanelId::NodeGraph,
PanelId::PointCloudManager,
PanelId::Count,
PanelId::SheetSetManager,
];
#[test]
fn dock_panel_visible_covers_every_panel() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.show_properties = true;
app.show_block_palette = true;
app.show_external_references = true;
app.show_browser = true;
app.show_node_graph = true;
app.pc_manager.show = true;
app.count_palette.show = true;
app.sheet_set.show = true;
for id in ALL {
assert!(app.dock_panel_visible(id), "{id:?} must be visible");
}
app.clean_screen = true;
for id in ALL {
assert!(
!app.dock_panel_visible(id),
"{id:?} must hide on clean screen"
);
}
}
#[test]
fn dock_panel_hidden_on_start_tab() {
// Fresh app, no drawing opened: the welcome tab shows no panels.
let app = OpenCADStudio::new_for_test();
assert!(app.tabs[app.active_tab].is_start);
for id in ALL {
assert!(!app.dock_panel_visible(id), "{id:?} must hide on Start");
}
}
}
#[cfg(test)]
mod free_text_entry_tests {
//! The free-form text prompts (table cell content, TEXT / MTEXT bodies)
@@ -11268,7 +11505,9 @@ mod free_text_entry_tests {
app.clipboard = vec![codec::EntityType::Line(codec::entities::Line::default())];
// A dialog field pasted natively: no command-line text, no PASTECLIP.
let _ = app.update(Message::PasteShortcutResolved(crate::app::PasteFocus::Field));
let _ = app.update(Message::PasteShortcutResolved(
crate::app::PasteFocus::Field,
));
assert_eq!(app.command_line.input, "EXISTING");
assert!(app.tabs[0].active_cmd.is_none());
}
@@ -11287,7 +11526,10 @@ mod free_text_entry_tests {
// Copied objects paste even though the command line holds focus.
app.clipboard = vec![codec::EntityType::Line(codec::entities::Line::default())];
let _ = app.update(Message::PasteShortcutResolved(PasteFocus::CommandLine));
assert!(app.tabs[0].active_cmd.as_ref().is_some_and(|cmd| cmd.name() == "PASTECLIP"));
assert!(app.tabs[0]
.active_cmd
.as_ref()
.is_some_and(|cmd| cmd.name() == "PASTECLIP"));
}
}