forked from 1b-agent/opencadstudio-upstream
- Double-clicking a locked object no longer opens its text / attribute / in-place block editor. - Clicking a locked object during a command's object-gather no longer counts as completing the selection (don't set selection_just_completed on the locked path). - copy_entities skips locked entities. - The attribute editor's Apply refuses to write when the block's layer was locked while the dialog was open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1443 lines
69 KiB
Rust
1443 lines
69 KiB
Rust
//! `command` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
|
|
|
|
#![allow(unused_imports)]
|
|
use super::util::*;
|
|
use super::{format_size, VIEWCUBE_HIT_SIZE};
|
|
use crate::app::helpers::{
|
|
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
|
|
CoordKind,
|
|
};
|
|
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
|
|
use crate::modules::ModuleEvent;
|
|
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
|
|
use crate::scene::model::object::GripApply;
|
|
use crate::scene::{
|
|
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
|
};
|
|
use crate::ui::PropertiesPanel;
|
|
use crate::ui::window::attribute_editor::{AttrRow, AttrTab};
|
|
use acadrust::types::Color as AcadColor;
|
|
use acadrust::{EntityType as AcadEntityType, Handle};
|
|
use iced::time::Instant;
|
|
use iced::{mouse, Point, Task};
|
|
|
|
|
|
impl OpenCADStudio {
|
|
pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|
// Start tab is fixed — close requests on it are no-ops.
|
|
if self.tabs.get(idx).map_or(false, |t| t.is_start) {
|
|
return Task::none();
|
|
}
|
|
// Closing a tab shifts indices / the active tab. The attribute
|
|
// editor holds a document-local handle into one tab, so drop it
|
|
// now rather than risk it applying to a different tab's document.
|
|
self.cancel_attr_editor();
|
|
if self.tabs.get(idx).map_or(false, |t| t.dirty) {
|
|
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
|
|
return self.open_unsaved_dialog_window();
|
|
}
|
|
// This tab is closing for good — drop its autosave recovery copy.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
let _ = std::fs::remove_file(self.autosave_target(idx));
|
|
// Only-tab case: when the lone non-start tab closes, fall
|
|
// back to the Start tab if it exists; otherwise spawn a
|
|
// fresh blank drawing (legacy behaviour).
|
|
if self.tabs.len() == 1 {
|
|
self.tab_counter += 1;
|
|
self.tabs[0] = crate::app::document::DocumentTab::new_drawing(self.tab_counter);
|
|
self.active_tab = 0;
|
|
self.apply_bg_default(0);
|
|
} else {
|
|
self.tabs.remove(idx);
|
|
if self.active_tab >= self.tabs.len() {
|
|
self.active_tab = self.tabs.len() - 1;
|
|
}
|
|
}
|
|
// The active tab is now either a brand-new blank or a
|
|
// different existing tab; in both cases the ribbon needs
|
|
// to track that doc's defaults / selection. #21.
|
|
self.sync_ribbon_layers();
|
|
self.sync_ribbon_styles();
|
|
self.sync_ribbon_from_selection();
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_command_append_char(&mut self, s: String) -> Task<Message> {
|
|
// While the MText preview is up, typed glyphs edit it directly.
|
|
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
|
|
if s.chars().all(|c| !c.is_control()) {
|
|
self.mtext_type(&s);
|
|
}
|
|
return Task::none();
|
|
}
|
|
// Filter out control characters — only push the typed
|
|
// glyph(s). `Tab`, etc. arrive as Named keys, not here.
|
|
if s.chars().all(|c| !c.is_control()) {
|
|
let i = self.active_tab;
|
|
// `,` is the coordinate separator in dynamic input,
|
|
// not a decimal point: typing it locks the current
|
|
// field's buffer and advances to the next coordinate,
|
|
// reshaping the field set when going polar → cartesian
|
|
// (Distance → X, Y) or 2-D → 3-D (X, Y → X, Y, Z).
|
|
// See #35.
|
|
if s == "," && self.dyn_input && !self.tabs[i].dyn_fields.is_empty() {
|
|
self.dyn_comma_advance();
|
|
self.command_line.autocomplete_cursor = None;
|
|
return self.focus_cmd_input();
|
|
}
|
|
// While dynamic input is showing fields, numeric and
|
|
// expression glyphs edit the focused field instead of
|
|
// the command line. Letters still go to the command line
|
|
// so command-option keywords keep working.
|
|
let dyn_field_char = !s.is_empty()
|
|
&& s.chars().all(|c| {
|
|
c.is_ascii_digit()
|
|
|| matches!(c, '.' | '-' | '+' | '*' | '/' | '^' | '%' | '(' | ')')
|
|
});
|
|
if dyn_field_char && self.dyn_input && !self.tabs[i].dyn_fields.is_empty() {
|
|
let a = self.tabs[i]
|
|
.dyn_active
|
|
.min(self.tabs[i].dyn_fields.len() - 1);
|
|
self.tabs[i].dyn_fields[a]
|
|
.buffer
|
|
.get_or_insert_with(String::new)
|
|
.push_str(&s);
|
|
} else {
|
|
self.command_line.input.push_str(&s);
|
|
}
|
|
}
|
|
self.command_line.autocomplete_cursor = None;
|
|
self.focus_cmd_input()
|
|
}
|
|
|
|
pub(super) fn on_command_backspace(&mut self) -> Task<Message> {
|
|
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
|
|
self.mtext_backspace();
|
|
return Task::none();
|
|
}
|
|
let i = self.active_tab;
|
|
// Backspace edits the focused dynamic-input field first;
|
|
// emptying it unlocks the field (back to cursor tracking).
|
|
if self.dyn_input && !self.tabs[i].dyn_fields.is_empty() {
|
|
let a = self.tabs[i]
|
|
.dyn_active
|
|
.min(self.tabs[i].dyn_fields.len() - 1);
|
|
if let Some(buf) = self.tabs[i].dyn_fields[a].buffer.as_mut() {
|
|
buf.pop();
|
|
if buf.is_empty() {
|
|
self.tabs[i].dyn_fields[a].buffer = None;
|
|
}
|
|
return self.focus_cmd_input();
|
|
}
|
|
}
|
|
self.command_line.input.pop();
|
|
self.command_line.autocomplete_cursor = None;
|
|
self.focus_cmd_input()
|
|
}
|
|
|
|
pub(super) fn on_command_submit(&mut self) -> Task<Message> {
|
|
// Submitting a command implicitly dismisses the history
|
|
// dropdown so the dispatched command's new prompt is
|
|
// immediately visible on the overlay.
|
|
self.command_line.close_history();
|
|
// Grip-menu value prompt — consume the typed number and
|
|
// route it through `apply_grip_menu_value`.
|
|
if let Some(pending) = self.grip_pending.take() {
|
|
let raw = crate::app::expr_eval::eval_to_string(self.command_line.input.trim());
|
|
self.command_line.input.clear();
|
|
let Ok(v) = raw.parse::<f64>() else {
|
|
self.command_line.push_error(&format!(
|
|
"{}: expected a number, got \"{raw}\"",
|
|
pending.label
|
|
));
|
|
return Task::none();
|
|
};
|
|
let i = self.active_tab;
|
|
use crate::entities::traits::EntityTypeOps;
|
|
self.push_undo_snapshot(i, pending.label);
|
|
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(pending.handle)
|
|
{
|
|
entity.apply_grip_menu_value(pending.grip_id, pending.action, v);
|
|
}
|
|
self.tabs[i].scene.bump_geometry();
|
|
self.tabs[i].dirty = true;
|
|
self.refresh_selected_grips();
|
|
self.refresh_properties();
|
|
return Task::none();
|
|
}
|
|
// Interactive VPORTS: the entry after a bare `VPORTS` is the
|
|
// tiled configuration. Empty input defaults to SINGLE.
|
|
if self.awaiting_vports {
|
|
self.awaiting_vports = false;
|
|
let cfg = self.command_line.input.trim().to_string();
|
|
self.command_line.input.clear();
|
|
let cfg = if cfg.is_empty() {
|
|
"SINGLE".to_string()
|
|
} else {
|
|
cfg
|
|
};
|
|
return self.dispatch_command(&format!("VPORTS {cfg}"));
|
|
}
|
|
// If the user navigated the autocomplete list with the
|
|
// arrow keys, Enter dispatches the highlighted command
|
|
// rather than the partial text actually in the buffer.
|
|
let i_tab = self.active_tab;
|
|
if self.tabs[i_tab].active_cmd.is_none() {
|
|
if let Some(picked) = self.command_line.selected_suggestion() {
|
|
let cmd = picked.to_string();
|
|
self.command_line.input.clear();
|
|
self.command_line.autocomplete_cursor = None;
|
|
return self.dispatch_command(&cmd);
|
|
}
|
|
}
|
|
let i = self.active_tab;
|
|
// A whole multi-token command line (`UCS Z 90`, `LINE 0,0
|
|
// 10,10`, `PDMODE 3`) — typable now that Space is literal — is
|
|
// processed as one unit: feed the tokens to a running command,
|
|
// or start a new one through the shared runner that the headless
|
|
// automation feeder uses too.
|
|
{
|
|
// Skip token-splitting when the active command collects
|
|
// free-form text with spaces (TEXT / MTEXT / a name) — it
|
|
// wants the whole line as one input.
|
|
let wants_spaces = self.tabs[i]
|
|
.active_cmd
|
|
.as_ref()
|
|
.map(|c| c.wants_text_input() && c.wants_text_with_spaces())
|
|
.unwrap_or(false);
|
|
let raw = self.command_line.input.clone();
|
|
let toks: Vec<String> = raw.split_whitespace().map(String::from).collect();
|
|
if toks.len() > 1 && !wants_spaces {
|
|
self.command_line.input.clear();
|
|
if self.tabs[i].active_cmd.is_some() {
|
|
for tok in &toks {
|
|
if self.tabs[i].active_cmd.is_none() {
|
|
break;
|
|
}
|
|
self.feed_active_cmd(tok);
|
|
}
|
|
return Task::none();
|
|
}
|
|
return self.run_command_line(&raw);
|
|
}
|
|
}
|
|
// With the command line empty, a typed dynamic-input value
|
|
// commits as a point pick instead of an empty submit.
|
|
if self.tabs[i].active_cmd.is_some() && self.command_line.input.trim().is_empty() {
|
|
if let Some(task) = self.try_dyn_commit() {
|
|
return task;
|
|
}
|
|
}
|
|
if self.tabs[i].active_cmd.is_some() {
|
|
let text = crate::app::expr_eval::eval_to_string(self.command_line.input.trim());
|
|
self.command_line.input.clear();
|
|
|
|
// Offer the typed text to the command's option handler
|
|
// first (keywords like PLINE's A/L/C, a radius, …). If it
|
|
// consumes the text we're done; if it returns None the
|
|
// text falls through to the Enter / coordinate handling
|
|
// below, so a bare Enter still terminates and typed points
|
|
// still work when dynamic input is off. See #97.
|
|
if self.tabs[i]
|
|
.active_cmd
|
|
.as_ref()
|
|
.map(|c| c.wants_text_input())
|
|
.unwrap_or(false)
|
|
{
|
|
self.push_ucs_to_cmd(i);
|
|
if let Some(result) = self.tabs[i]
|
|
.active_cmd
|
|
.as_mut()
|
|
.and_then(|c| c.on_text_input(&text))
|
|
{
|
|
return self.apply_cmd_result(result);
|
|
}
|
|
}
|
|
|
|
if text.is_empty() {
|
|
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_enter());
|
|
if let Some(r) = result {
|
|
return self.apply_cmd_result(r);
|
|
}
|
|
return Task::none();
|
|
}
|
|
|
|
// OTRACK: while aligned to a tracking ray, a bare distance
|
|
// places the point along the ray from the tracking point
|
|
// (issue #69).
|
|
if let Some((base, dir)) = self.otrack_active {
|
|
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
|
|
let pt = base + dir * dist as f32;
|
|
self.last_point = Some(pt);
|
|
self.dyn_user_reshaped = false;
|
|
self.sync_dyn_fields();
|
|
self.reset_tracking_after_point();
|
|
self.push_ucs_to_cmd(i);
|
|
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt.as_dvec3()));
|
|
if let Some(r) = result {
|
|
let task = self.apply_cmd_result(r);
|
|
self.refresh_active_cmd_preview(i);
|
|
return task;
|
|
}
|
|
return Task::none();
|
|
}
|
|
}
|
|
|
|
if let Some((coord, kind)) = parse_coord(&text) {
|
|
// Command-line coordinates are absolute by default,
|
|
// independent of DYN: `@` forces relative, `#` forces
|
|
// absolute, and a bare value is absolute. (Relative-by-
|
|
// default lives in the DYN tooltip path — see
|
|
// `dyn_resolve_point` — matching AutoCAD, where the
|
|
// command line stays absolute regardless of DYN.)
|
|
let want_relative = matches!(kind, CoordKind::Relative);
|
|
let ucs = self.tabs[i].active_ucs.clone();
|
|
let wcs_pt = match (want_relative, self.last_point) {
|
|
(true, Some(base)) => {
|
|
// Offset from the last point, rotated by the
|
|
// UCS axes (no origin translation).
|
|
let offset = match &ucs {
|
|
Some(u) => ucs_rotate_vec(coord, u),
|
|
None => coord,
|
|
};
|
|
base + offset
|
|
}
|
|
_ => {
|
|
// Absolute: typed coordinates are in active UCS.
|
|
match &ucs {
|
|
Some(u) => ucs_to_wcs(coord, u),
|
|
None => coord,
|
|
}
|
|
}
|
|
};
|
|
self.last_point = Some(wcs_pt);
|
|
self.dyn_user_reshaped = false;
|
|
self.sync_dyn_fields();
|
|
self.reset_tracking_after_point();
|
|
self.push_ucs_to_cmd(i);
|
|
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(wcs_pt.as_dvec3()));
|
|
if let Some(r) = result {
|
|
let task = self.apply_cmd_result(r);
|
|
// The rubber-band preview that the command
|
|
// last published reflects the *previous*
|
|
// last_point — a typed coordinate doesn't
|
|
// fire a mouse-move, so re-run the preview
|
|
// hook now using the current cursor world
|
|
// pos so the next segment immediately starts
|
|
// from the just-committed point. See #32.
|
|
self.refresh_active_cmd_preview(i);
|
|
return task;
|
|
}
|
|
return Task::none();
|
|
}
|
|
|
|
self.push_ucs_to_cmd(i);
|
|
if let Some(result) = self.tabs[i]
|
|
.active_cmd
|
|
.as_mut()
|
|
.and_then(|c| c.on_text_input(&text))
|
|
{
|
|
return self.apply_cmd_result(result);
|
|
}
|
|
|
|
self.command_line.push_error(&format!(
|
|
"Expected coordinates (x,y) or a number, got: \"{text}\""
|
|
));
|
|
return self.focus_cmd_input();
|
|
}
|
|
if let Some(cmd) = self.command_line.submit() {
|
|
return self.dispatch_command_or_suggest(&cmd);
|
|
}
|
|
// Empty Enter / Space with no active command repeats the
|
|
// last dispatched command — same shortcut `CommandFinalize`
|
|
// already implements, mirrored here so the trailing-space
|
|
// submit path goes through it too.
|
|
if let Some(cmd) = self.tabs[i].last_cmd.clone() {
|
|
return self.dispatch_command(&cmd);
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_command_finalize(&mut self) -> Task<Message> {
|
|
// In the MText preview, Enter inserts a line break.
|
|
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
|
|
self.mtext_type("\n");
|
|
return Task::none();
|
|
}
|
|
// Grip popup open → Enter commits the highlighted item.
|
|
if self.grip_popup.is_some() {
|
|
let idx = self.grip_popup.as_ref().map(|p| p.selected).unwrap_or(0);
|
|
return Task::done(Message::GripMenuPick(idx));
|
|
}
|
|
// Any typed command-line text must be submitted rather than
|
|
// finalising. The focused-input Enter routes through
|
|
// CommandSubmit, but when the field isn't focused — e.g. at
|
|
// startup before the window grabs focus, or a command started
|
|
// from the ribbon — its Enter arrives here. Forward a non-empty
|
|
// buffer to the same submit path so typing a command name (or
|
|
// an option keyword like "R") and pressing Enter works without
|
|
// first clicking into the command line (issue #99).
|
|
if !self.command_line.input.trim().is_empty() {
|
|
return self.update(Message::CommandSubmit);
|
|
}
|
|
// A typed dynamic-input value commits as a point pick
|
|
// before the plain-Enter (on_enter) path runs.
|
|
if let Some(task) = self.try_dyn_commit() {
|
|
return task;
|
|
}
|
|
let i = self.active_tab;
|
|
if self.tabs[i].active_cmd.is_some() {
|
|
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_enter());
|
|
if let Some(r) = result {
|
|
return self.apply_cmd_result(r);
|
|
}
|
|
Task::none()
|
|
} else if let Some(cmd) = self.tabs[i].last_cmd.clone() {
|
|
self.dispatch_command(&cmd)
|
|
} else {
|
|
Task::none()
|
|
}
|
|
}
|
|
|
|
pub(super) fn on_command_escape(&mut self) -> Task<Message> {
|
|
// Open MText editor swallows Escape (cancel without committing).
|
|
if self.mtext_editor.is_some() {
|
|
self.mtext_cancel();
|
|
return self.post_editor_closed(false);
|
|
}
|
|
// The in-place TEXT editor likewise cancels on Escape.
|
|
if self.text_inline.is_some() {
|
|
self.text_inline_cancel();
|
|
return self.post_editor_closed(false);
|
|
}
|
|
// Esc cancels an armed pane move.
|
|
if self.pane_move_from.take().is_some() {
|
|
return Task::none();
|
|
}
|
|
// UCS icon: Esc ends any grip drag and clears the selection
|
|
// (only when no command owns Escape).
|
|
if self.tabs[self.active_tab].active_cmd.is_none()
|
|
&& (self.ucs_grip_drag.is_some() || self.ucs_icon_selected)
|
|
{
|
|
let i = self.active_tab;
|
|
self.ucs_grip_drag = None;
|
|
self.ucs_icon_selected = false;
|
|
self.ucs_icon_hover = false;
|
|
self.tabs[i].snap_result = None;
|
|
return Task::none();
|
|
}
|
|
// Leave interactive PAN mode (and end any in-flight pan drag).
|
|
if self.tabs[self.active_tab].pan_mode {
|
|
let i = self.active_tab;
|
|
self.tabs[i].pan_mode = false;
|
|
{
|
|
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
|
sel.middle_down = false;
|
|
sel.middle_last_pos = None;
|
|
}
|
|
self.command_line.push_output("PAN ended.");
|
|
return Task::none();
|
|
}
|
|
// Grip popup intercepts Escape — dismisses the menu
|
|
// without doing anything else.
|
|
if self.grip_popup.take().is_some() {
|
|
self.grip_hover = None;
|
|
return Task::none();
|
|
}
|
|
if self.visibility_popup.take().is_some() {
|
|
return Task::none();
|
|
}
|
|
if self.grip_pending.take().is_some() {
|
|
self.command_line.input.clear();
|
|
return Task::none();
|
|
}
|
|
// A hot grip (click-move-click placement in progress) ends on
|
|
// Escape, leaving the entity at its last previewed position.
|
|
if self.tabs[self.active_tab].active_grip.take().is_some() {
|
|
// An Add-Leader arrow being placed: Esc removes it again.
|
|
if let Some((h, gid)) = self.grip_add_provisional.take() {
|
|
let i = self.active_tab;
|
|
use crate::entities::traits::EntityTypeOps;
|
|
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(h) {
|
|
e.apply_grip_menu(
|
|
gid,
|
|
crate::scene::model::object::GripMenuAction::RemoveLeader,
|
|
);
|
|
}
|
|
self.tabs[i].scene.bump_geometry();
|
|
self.refresh_selected_grips();
|
|
}
|
|
// Cancel an in-progress grip drag: restore the edited
|
|
// entity from its pre-drag backup, un-hide it, re-tessellate
|
|
// once, and drop the preview.
|
|
if let Some(h) = self.grip_preview_handle.take() {
|
|
let i = self.active_tab;
|
|
if let Some(orig) = self.grip_original.take() {
|
|
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(h) {
|
|
*e = orig;
|
|
}
|
|
}
|
|
self.tabs[i].scene.hidden.remove(&h);
|
|
self.tabs[i].scene.clear_preview_wire();
|
|
// Geometry restored to the backup — re-tessellate just it.
|
|
self.tabs[i].scene.mark_entity_dirty(h);
|
|
self.tabs[i].scene.bump_geometry_no_blocks();
|
|
self.refresh_selected_grips();
|
|
}
|
|
self.tabs[self.active_tab].snap_result = None;
|
|
self.refresh_properties();
|
|
return Task::none();
|
|
}
|
|
// Cancel layout rename / context menus first, then fall through.
|
|
let i_e = self.active_tab;
|
|
if self.qselect.take().is_some() {
|
|
return Task::none();
|
|
}
|
|
{
|
|
let mut sel = self.tabs[i_e].scene.selection.borrow_mut();
|
|
if sel.context_menu.is_some() {
|
|
sel.context_menu = None;
|
|
return Task::none();
|
|
}
|
|
}
|
|
if self.layout_rename_state.take().is_some()
|
|
|| self.layout_context_menu.take().is_some()
|
|
{
|
|
return Task::none();
|
|
}
|
|
// Typed text on the command line cancels first — one
|
|
// Esc empties the buffer, a second Esc then escalates
|
|
// to whatever the current mode would otherwise do
|
|
// (cancel command / exit viewport / deselect).
|
|
if !self.command_line.input.is_empty() {
|
|
self.command_line.input.clear();
|
|
self.command_line.autocomplete_cursor = None;
|
|
self.command_line.close_history();
|
|
return Task::none();
|
|
}
|
|
let i = self.active_tab;
|
|
if self.tabs[i].active_cmd.is_some() {
|
|
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_escape());
|
|
if let Some(r) = result {
|
|
return self.apply_cmd_result(r);
|
|
}
|
|
} else if self.tabs[i].scene.active_viewport.is_some() {
|
|
// ESC while in MSPACE → exit back to paper space.
|
|
return Task::done(Message::ExitViewport);
|
|
} else {
|
|
self.tabs[i].scene.deselect_all();
|
|
self.refresh_properties();
|
|
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
|
sel.box_anchor = None;
|
|
sel.box_current = None;
|
|
sel.box_crossing = false;
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_layer_toggle_vp_freeze(&mut self, layer_idx: usize, vp_col_idx: usize) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
let vp_handle = self.tabs[i]
|
|
.layers
|
|
.vp_cols
|
|
.get(vp_col_idx)
|
|
.map(|c| c.handle);
|
|
let layer_name = self.tabs[i]
|
|
.layers
|
|
.layers
|
|
.get(layer_idx)
|
|
.map(|l| l.name.clone());
|
|
|
|
if let (Some(vp_handle), Some(layer_name)) = (vp_handle, layer_name) {
|
|
// Get the layer handle from the document
|
|
if let Some(doc_layer) = self.tabs[i].scene.document.layers.get(&layer_name) {
|
|
let layer_handle = doc_layer.handle;
|
|
self.push_undo_snapshot(i, "VPLAYER");
|
|
|
|
// Toggle frozen_layers on the viewport entity
|
|
for e in self.tabs[i].scene.document.entities_mut() {
|
|
if let acadrust::EntityType::Viewport(vp) = e {
|
|
if vp.common.handle == vp_handle {
|
|
if vp.frozen_layers.contains(&layer_handle) {
|
|
vp.frozen_layers.retain(|h| h != &layer_handle);
|
|
} else {
|
|
vp.frozen_layers.push(layer_handle);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-sync layer panel with updated VP info
|
|
let vp_info = self.tabs[i].scene.viewport_list();
|
|
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
|
self.tabs[i]
|
|
.layers
|
|
.sync_with_viewports(&doc_layers, vp_info);
|
|
self.tabs[i].scene.bump_geometry();
|
|
self.tabs[i].dirty = true;
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_layer_new(&mut self) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
let mut n = 1;
|
|
let new_name = loop {
|
|
let candidate = format!("Layer{}", n);
|
|
if !self.tabs[i].scene.document.layers.contains(&candidate) {
|
|
break candidate;
|
|
}
|
|
n += 1;
|
|
};
|
|
self.push_undo_snapshot(i, "LAYER NEW");
|
|
use acadrust::tables::layer::Layer as DocLayer;
|
|
// A layer needs a real handle or it is dropped on a DWG save
|
|
// (the format is handle-based; issue #67).
|
|
let mut dl = DocLayer::new(&new_name);
|
|
// `allocate_handle` advances the seed so the layer gets a
|
|
// unique handle; the non-advancing `next_handle` getter hands
|
|
// out the same value twice and the later object overwrites it.
|
|
dl.handle = self.tabs[i].scene.document.allocate_handle();
|
|
let _ = self.tabs[i].scene.document.layers.add(dl);
|
|
self.tabs[i].dirty = true;
|
|
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
|
let vp_info = self.tabs[i].scene.viewport_list();
|
|
self.tabs[i]
|
|
.layers
|
|
.sync_with_viewports(&doc_layers, vp_info);
|
|
let new_idx = self.tabs[i]
|
|
.layers
|
|
.layers
|
|
.iter()
|
|
.position(|l| l.name == new_name);
|
|
if let Some(idx) = new_idx {
|
|
self.tabs[i].layers.selected = Some(idx);
|
|
self.tabs[i].layers.editing = Some(idx);
|
|
self.tabs[i].layers.edit_buf = new_name.clone();
|
|
}
|
|
self.sync_ribbon_layers();
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_layer_delete(&mut self) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
if let Some(idx) = self.tabs[i].layers.selected {
|
|
let name = self.tabs[i]
|
|
.layers
|
|
.layers
|
|
.get(idx)
|
|
.map(|l| l.name.clone())
|
|
.unwrap_or_default();
|
|
if name == "0" {
|
|
return Task::none();
|
|
}
|
|
self.push_undo_snapshot(i, "LAYER DELETE");
|
|
self.tabs[i].scene.document.layers.remove(&name);
|
|
self.tabs[i].dirty = true;
|
|
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
|
let vp_info = self.tabs[i].scene.viewport_list();
|
|
self.tabs[i]
|
|
.layers
|
|
.sync_with_viewports(&doc_layers, vp_info);
|
|
self.tabs[i].layers.selected = None;
|
|
self.sync_ribbon_layers();
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_layer_set_current(&mut self) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
if let Some(idx) = self.tabs[i].layers.selected {
|
|
if let Some(layer) = self.tabs[i].layers.layers.get(idx) {
|
|
let name = layer.name.clone();
|
|
// Mirror the change into the document header (CLAYER) too,
|
|
// not just the per-tab default. Otherwise the no-selection
|
|
// ribbon refresh (e.g. after Esc) re-reads the stale header
|
|
// layer and the dropdown snaps back to it. See #93.
|
|
let handle = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.layers
|
|
.get(&name)
|
|
.map(|l| l.handle)
|
|
.unwrap_or(acadrust::types::Handle::NULL);
|
|
self.tabs[i].scene.document.header.current_layer_name = name.clone();
|
|
self.tabs[i].scene.document.header.current_layer_handle = handle;
|
|
self.tabs[i].active_layer = name.clone();
|
|
self.tabs[i].layers.current_layer = name.clone();
|
|
self.tabs[i].dirty = true;
|
|
self.ribbon.active_layer = name;
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_layer_rename_commit(&mut self) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
let editing_idx = self.tabs[i].layers.editing.take();
|
|
if let Some(idx) = editing_idx {
|
|
let new_name = self.tabs[i].layers.edit_buf.trim().to_string();
|
|
let old_name = self.tabs[i]
|
|
.layers
|
|
.layers
|
|
.get(idx)
|
|
.map(|l| l.name.clone())
|
|
.unwrap_or_default();
|
|
if !new_name.is_empty()
|
|
&& new_name != old_name
|
|
&& !self.tabs[i].scene.document.layers.contains(&new_name)
|
|
{
|
|
self.push_undo_snapshot(i, "LAYER RENAME");
|
|
// Keep the whole record (handle, color, linetype,
|
|
// lineweight, flags) and only change the name, so the
|
|
// renamed layer still has a valid handle and survives a
|
|
// DWG save (issue #67).
|
|
if let Some(mut nl) =
|
|
self.tabs[i].scene.document.layers.get(&old_name).cloned()
|
|
{
|
|
nl.name = new_name.clone();
|
|
if !nl.handle.is_valid() {
|
|
nl.handle = self.tabs[i].scene.document.allocate_handle();
|
|
}
|
|
let _ = self.tabs[i].scene.document.layers.add(nl);
|
|
}
|
|
self.tabs[i].scene.document.layers.remove(&old_name);
|
|
for e in self.tabs[i].scene.document.entities_mut() {
|
|
if e.as_entity().layer() == old_name {
|
|
e.as_entity_mut().set_layer(new_name.clone());
|
|
}
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
}
|
|
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
|
let vp_info = self.tabs[i].scene.viewport_list();
|
|
self.tabs[i]
|
|
.layers
|
|
.sync_with_viewports(&doc_layers, vp_info);
|
|
self.tabs[i].layers.edit_buf.clear();
|
|
self.sync_ribbon_layers();
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_grip_menu_pick(&mut self, idx: usize) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
let Some(popup) = self.grip_popup.take() else {
|
|
return Task::none();
|
|
};
|
|
self.grip_hover = None;
|
|
let Some(item) = popup.items.get(idx).cloned() else {
|
|
return Task::none();
|
|
};
|
|
use crate::entities::traits::EntityTypeOps;
|
|
use crate::scene::model::object::GripMenuAction;
|
|
if matches!(
|
|
item.action,
|
|
GripMenuAction::Stretch
|
|
| GripMenuAction::MoveWithLeader
|
|
| GripMenuAction::MoveIndependent
|
|
) {
|
|
// Stretch / Move = grab this grip. Engage it so the next
|
|
// click places it (click-move-click) — same as picking the
|
|
// grip directly in the viewport. Without this the menu just
|
|
// closed and the grip never became hot (issue #48).
|
|
if let Some(g) = self.tabs[i]
|
|
.selected_grips
|
|
.iter()
|
|
.find(|g| g.id == popup.grip_id)
|
|
{
|
|
// "Move with Leader" drags the whole multileader; the
|
|
// others move just the picked grip.
|
|
let (grip_id, is_translate) =
|
|
if matches!(item.action, GripMenuAction::MoveWithLeader) {
|
|
(crate::entities::multileader::MOVE_ALL_GRIP, true)
|
|
} else {
|
|
(popup.grip_id, g.is_midpoint)
|
|
};
|
|
self.tabs[i].active_grip = Some(GripEdit {
|
|
handle: popup.handle,
|
|
grip_id,
|
|
is_translate,
|
|
origin_world: g.world,
|
|
last_world: g.world,
|
|
});
|
|
}
|
|
return Task::none();
|
|
}
|
|
// Actions that need a follow-up number stash a pending
|
|
// state + prompt; the next typed value drives
|
|
// `apply_grip_menu_value`.
|
|
let prompt = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity(popup.handle)
|
|
.and_then(|e| e.grip_menu_value_prompt(popup.grip_id, item.action));
|
|
if let Some(label) = prompt {
|
|
self.grip_pending = Some(crate::app::GripPendingValue {
|
|
handle: popup.handle,
|
|
grip_id: popup.grip_id,
|
|
action: item.action,
|
|
label,
|
|
});
|
|
self.command_line.push_info(&format!("{label}:"));
|
|
return self.focus_cmd_input();
|
|
}
|
|
// One-shot action — apply immediately.
|
|
self.push_undo_snapshot(i, item.label);
|
|
// For Add Leader, the new arrow becomes the last grip; remember
|
|
// its id so we can grab it for placement right after.
|
|
let add_leader_gid = if matches!(item.action, GripMenuAction::AddLeader) {
|
|
self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity(popup.handle)
|
|
.and_then(|e| match e {
|
|
acadrust::EntityType::MultiLeader(ml) => Some(
|
|
ml.context
|
|
.leader_roots
|
|
.iter()
|
|
.flat_map(|r| r.lines.iter())
|
|
.map(|l| l.points.len())
|
|
.sum::<usize>(),
|
|
),
|
|
_ => None,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(popup.handle) {
|
|
entity.apply_grip_menu(popup.grip_id, item.action);
|
|
}
|
|
self.tabs[i].scene.bump_geometry();
|
|
self.tabs[i].dirty = true;
|
|
self.refresh_selected_grips();
|
|
self.refresh_properties();
|
|
// Grab the new arrow so it follows the cursor (click places it,
|
|
// Esc removes it).
|
|
if let Some(new_gid) = add_leader_gid {
|
|
if let Some(g) = self.tabs[i].selected_grips.iter().find(|g| g.id == new_gid) {
|
|
self.tabs[i].active_grip = Some(GripEdit {
|
|
handle: popup.handle,
|
|
grip_id: new_gid,
|
|
is_translate: false,
|
|
origin_world: g.world,
|
|
last_world: g.world,
|
|
});
|
|
self.grip_add_provisional = Some((popup.handle, new_gid));
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_paste_shortcut(&mut self) -> Task<Message> {
|
|
if self.mtext_editor.is_some() {
|
|
// Web reads via the browser's async Clipboard API (iced's
|
|
// sync clipboard read returns nothing there); native uses
|
|
// iced's clipboard.
|
|
#[cfg(target_arch = "wasm32")]
|
|
return Task::perform(
|
|
crate::sys::read_clipboard_text(),
|
|
Message::MTextPasteClip,
|
|
);
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
return iced::clipboard::read().map(Message::MTextPasteClip);
|
|
}
|
|
if self.text_inline.is_some() {
|
|
// Web: the iced text_input can't reach the async clipboard,
|
|
// so paste it ourselves. Native: the focused text_input
|
|
// already handled Ctrl+V — doing it here would duplicate.
|
|
#[cfg(target_arch = "wasm32")]
|
|
return Task::perform(
|
|
crate::sys::read_clipboard_text(),
|
|
Message::TextInlinePasteClip,
|
|
);
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
return Task::none();
|
|
}
|
|
Task::done(Message::Command("PASTECLIP".to_string()))
|
|
}
|
|
|
|
pub(super) fn on_qselect_open(&mut self) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
|
|
// Seed the type filter from the first selected entity so a
|
|
// right-click → Quick Select on a known object opens the
|
|
// panel pre-tuned to that entity's type. Property defaults
|
|
// to "(Any property)" so the user immediately picks what
|
|
// they want to compare.
|
|
let mut type_filter: Option<String> = None;
|
|
if let Some(&h) = self.tabs[i].scene.selected.iter().next() {
|
|
if let Some(e) = self.tabs[i].scene.document.get_entity(h) {
|
|
use crate::entities::traits::entity_type_name;
|
|
type_filter = Some(entity_type_name(e).to_string());
|
|
}
|
|
}
|
|
self.qselect = Some(crate::app::QSelectState {
|
|
type_filter,
|
|
property: None,
|
|
operator: crate::app::QSelectOp::Eq,
|
|
value: String::new(),
|
|
append: false,
|
|
});
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_ribbon_layer_changed(&mut self, layer: String) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
self.ribbon.close_dropdown();
|
|
let handles = self.property_target_handles(i);
|
|
if handles.is_empty() {
|
|
// No selection — change the creation default. Persist
|
|
// into the tab's header (CLAYER) so it survives a tab
|
|
// switch and rides the next save. #21.
|
|
let handle = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.layers
|
|
.get(&layer)
|
|
.map(|l| l.handle)
|
|
.unwrap_or(acadrust::types::Handle::NULL);
|
|
self.tabs[i].scene.document.header.current_layer_name = layer.clone();
|
|
self.tabs[i].scene.document.header.current_layer_handle = handle;
|
|
self.tabs[i].active_layer = layer.clone();
|
|
self.tabs[i].layers.current_layer = layer.clone();
|
|
self.tabs[i].dirty = true;
|
|
self.ribbon.active_layer = layer;
|
|
} else {
|
|
// Apply to selection; leave the creation default alone
|
|
// (matches AutoCAD; "Make current" is a separate action).
|
|
self.push_undo_snapshot(i, "CHPROP");
|
|
for handle in handles {
|
|
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
|
crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer);
|
|
}
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.ribbon.active_layer = layer;
|
|
self.refresh_properties();
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_ribbon_color_changed(&mut self, color: AcadColor) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
self.ribbon.prop_color_palette_open = false;
|
|
self.ribbon.close_dropdown();
|
|
let handles = self.property_target_handles(i);
|
|
if handles.is_empty() {
|
|
// Persist the new default into the tab's header so it
|
|
// round-trips through tab switches and writes back on
|
|
// save (CECOLOR). #21.
|
|
self.tabs[i].scene.document.header.current_entity_color = color;
|
|
self.tabs[i].dirty = true;
|
|
self.ribbon.active_color = color;
|
|
} else {
|
|
self.push_undo_snapshot(i, "CHPROP");
|
|
for &handle in &handles {
|
|
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
|
crate::scene::view::dispatch::apply_color(entity, color);
|
|
}
|
|
}
|
|
self.invalidate_property_targets(i, &handles);
|
|
self.tabs[i].dirty = true;
|
|
self.ribbon.active_color = color;
|
|
self.refresh_properties();
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_ribbon_linetype_changed(&mut self, lt: String) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
self.ribbon.close_dropdown();
|
|
let handles = self.property_target_handles(i);
|
|
if handles.is_empty() {
|
|
// Persist into the tab's header (CELTYPE). Resolve to a
|
|
// handle when the name matches a line_types entry so the
|
|
// handle-based lookup stays in sync. #21.
|
|
let handle = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.line_types
|
|
.iter()
|
|
.find(|x| x.name.eq_ignore_ascii_case(<))
|
|
.map(|x| x.handle)
|
|
.unwrap_or(acadrust::types::Handle::NULL);
|
|
self.tabs[i].scene.document.header.current_linetype_name = lt.clone();
|
|
self.tabs[i].scene.document.header.current_linetype_handle = handle;
|
|
self.tabs[i].dirty = true;
|
|
self.ribbon.active_linetype = lt;
|
|
} else {
|
|
self.push_undo_snapshot(i, "CHPROP");
|
|
for handle in handles {
|
|
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
|
crate::scene::view::dispatch::apply_common_prop(entity, "linetype", <);
|
|
}
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
self.ribbon.active_linetype = lt;
|
|
self.refresh_properties();
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_ribbon_style_changed(&mut self, key: crate::modules::StyleKey, name: String) -> Task<Message> {
|
|
use crate::modules::StyleKey;
|
|
self.ribbon.close_dropdown();
|
|
match key {
|
|
StyleKey::TextStyle => {
|
|
self.ribbon.active_text_style = name.clone();
|
|
let i = self.active_tab;
|
|
let found = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.text_styles
|
|
.iter()
|
|
.find(|s| s.name == name)
|
|
.map(|ts| ts.handle);
|
|
if let Some(h) = found {
|
|
self.tabs[i].scene.document.header.current_text_style_handle = h;
|
|
self.tabs[i].scene.document.header.current_text_style_name = name;
|
|
}
|
|
}
|
|
StyleKey::DimStyle => {
|
|
self.ribbon.active_dim_style = name.clone();
|
|
let i = self.active_tab;
|
|
let found = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.dim_styles
|
|
.get(&name)
|
|
.map(|ds| ds.handle);
|
|
if let Some(h) = found {
|
|
self.tabs[i].scene.document.header.current_dimstyle_handle = h;
|
|
self.tabs[i].scene.document.header.current_dimstyle_name = name;
|
|
}
|
|
}
|
|
StyleKey::MLeaderStyle => {
|
|
self.ribbon.active_mleader_style = name.clone();
|
|
let i = self.active_tab;
|
|
self.tabs[i].active_mleader_style = name;
|
|
}
|
|
StyleKey::TableStyle => {
|
|
self.ribbon.active_table_style = name;
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_prop_hatch_pattern_changed(&mut self, name: String) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
let handles = self.property_target_handles(i);
|
|
if !handles.is_empty() {
|
|
use crate::scene::model::hatch_patterns;
|
|
if let Some(entry) = hatch_patterns::find(&name) {
|
|
self.push_undo_snapshot(i, "HATCHEDIT");
|
|
for &handle in &handles {
|
|
if let Some(acadrust::EntityType::Hatch(dxf)) =
|
|
self.tabs[i].scene.document.get_entity_mut(handle)
|
|
{
|
|
dxf.pattern = hatch_patterns::build_dxf_pattern(entry);
|
|
dxf.is_solid = matches!(
|
|
entry.gpu,
|
|
crate::scene::model::hatch_model::HatchPattern::Solid
|
|
);
|
|
}
|
|
if let Some(model) = self.tabs[i].scene.hatches.get_mut(&handle) {
|
|
model.pattern = entry.gpu.clone();
|
|
model.name = name.clone();
|
|
}
|
|
}
|
|
self.invalidate_property_targets(i, &handles);
|
|
self.tabs[i].dirty = true;
|
|
self.refresh_properties();
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_prop_geom_choice_changed(&mut self, field: &'static str, value: String) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
let handles = self.property_target_handles(i);
|
|
if !handles.is_empty() {
|
|
self.push_undo_snapshot(i, "CHPROP");
|
|
if field == "vp_ucs_name" {
|
|
// Resolve UCS name → cloned data, then mutate viewports.
|
|
let ucs_data = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.ucss
|
|
.iter()
|
|
.find(|u| u.name == value)
|
|
.cloned();
|
|
if let Some(ucs) = ucs_data {
|
|
for handle in &handles {
|
|
if let Some(acadrust::EntityType::Viewport(vp)) =
|
|
self.tabs[i].scene.document.get_entity_mut(*handle)
|
|
{
|
|
vp.ucs_handle = ucs.handle;
|
|
vp.ucs_origin = ucs.origin.clone();
|
|
vp.ucs_x_axis = ucs.x_axis.clone();
|
|
vp.ucs_y_axis = ucs.y_axis.clone();
|
|
}
|
|
}
|
|
}
|
|
} else if field == "vp_named_view" {
|
|
// Assign a named view to viewport(s): copy camera parameters.
|
|
let view_data = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.views
|
|
.iter()
|
|
.find(|v| v.name == value)
|
|
.cloned();
|
|
if let Some(view) = view_data {
|
|
for handle in &handles {
|
|
if let Some(acadrust::EntityType::Viewport(vp)) =
|
|
self.tabs[i].scene.document.get_entity_mut(*handle)
|
|
{
|
|
vp.view_target = view.target.clone();
|
|
vp.view_direction = view.direction.clone();
|
|
if view.height > 0.0 {
|
|
vp.view_height = view.height;
|
|
}
|
|
}
|
|
}
|
|
self.tabs[i].scene.camera_generation += 1;
|
|
}
|
|
} else {
|
|
for &handle in &handles {
|
|
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle)
|
|
{
|
|
crate::scene::view::dispatch::apply_geom_prop(entity, field, &value);
|
|
}
|
|
}
|
|
}
|
|
self.invalidate_property_targets(i, &handles);
|
|
self.tabs[i].dirty = true;
|
|
self.refresh_properties();
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_prop_geom_commit(&mut self, field: &'static str) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
let handles = self.property_target_handles(i);
|
|
if !handles.is_empty() {
|
|
if let Some(raw_val) = self.tabs[i].properties.edit_buf.remove(field) {
|
|
let val = crate::app::expr_eval::eval_to_string(&raw_val);
|
|
self.push_undo_snapshot(i, "CHPROP");
|
|
if field == "frozen_layers" {
|
|
// Resolve layer names → handles, then apply to viewports.
|
|
let layer_handles: Vec<acadrust::Handle> = val
|
|
.split(',')
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
.filter_map(|name| {
|
|
self.tabs[i]
|
|
.scene
|
|
.document
|
|
.layers
|
|
.iter()
|
|
.find(|l| l.name.eq_ignore_ascii_case(name))
|
|
.map(|l| l.handle)
|
|
})
|
|
.collect();
|
|
for &handle in &handles {
|
|
if let Some(acadrust::EntityType::Viewport(vp)) =
|
|
self.tabs[i].scene.document.get_entity_mut(handle)
|
|
{
|
|
vp.frozen_layers = layer_handles.clone();
|
|
}
|
|
}
|
|
} else {
|
|
for &handle in &handles {
|
|
// Skip objects on a locked layer.
|
|
if self.tabs[i].scene.is_layer_locked(handle) {
|
|
continue;
|
|
}
|
|
if let Some(entity) =
|
|
self.tabs[i].scene.document.get_entity_mut(handle)
|
|
{
|
|
match field {
|
|
"linetype_scale" | "transparency" => {
|
|
crate::scene::view::dispatch::apply_common_prop(
|
|
entity, field, &val,
|
|
);
|
|
}
|
|
_ => {
|
|
crate::scene::view::dispatch::apply_geom_prop(
|
|
entity, field, &val,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.invalidate_property_targets(i, &handles);
|
|
self.tabs[i].dirty = true;
|
|
self.refresh_properties();
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
/// Commit an edited block-attribute value from the Properties panel.
|
|
/// Writes the new value into the matching attribute (by tag) of every
|
|
/// selected INSERT, then re-tessellates so the attribute text repaints.
|
|
/// The value is stored verbatim (attribute values are free-form text, so
|
|
/// it is not run through the expression evaluator like numeric fields).
|
|
pub(super) fn on_prop_attr_commit(&mut self, tag: String) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
let key = crate::ui::properties::attr_edit_key(&tag);
|
|
let handles = self.property_target_handles(i);
|
|
if handles.is_empty() {
|
|
return Task::none();
|
|
}
|
|
let Some(val) = self.tabs[i].properties.edit_buf.remove(&key) else {
|
|
return Task::none();
|
|
};
|
|
self.push_undo_snapshot(i, "ATTEDIT");
|
|
for &handle in &handles {
|
|
if let Some(acadrust::EntityType::Insert(ins)) =
|
|
self.tabs[i].scene.document.get_entity_mut(handle)
|
|
{
|
|
if let Some(attr) = ins.attributes.iter_mut().find(|a| a.tag == tag) {
|
|
attr.set_value(val.clone());
|
|
}
|
|
}
|
|
}
|
|
self.invalidate_property_targets(i, &handles);
|
|
self.tabs[i].dirty = true;
|
|
self.refresh_properties();
|
|
Task::none()
|
|
}
|
|
|
|
/// Open the attribute editor dialog for the given INSERT, loading a working
|
|
/// copy of its attribute values. No-op (with a hint) when the block has no
|
|
/// attributes. Entry points: double-clicking such a block, or ATTEDIT.
|
|
pub(crate) fn open_attribute_editor(&mut self, handle: acadrust::Handle) {
|
|
let i = self.active_tab;
|
|
let doc = &self.tabs[i].scene.document;
|
|
// Ok((block, rows)) to open; Err(msg) to report and stay closed. The
|
|
// borrow of `doc` ends with this match, before any `self` mutation.
|
|
let result = match doc.get_entity(handle) {
|
|
Some(acadrust::EntityType::Insert(ins)) if !ins.attributes.is_empty() => {
|
|
// The prompt text lives on the block's ATTDEFs, not on the
|
|
// attribute instances — map tag → prompt from the definition.
|
|
let prompts = block_attr_prompts(doc, &ins.block_name);
|
|
let rows = ins
|
|
.attributes
|
|
.iter()
|
|
.map(|a| attr_row_from_entity(a, &prompts))
|
|
.collect::<Vec<_>>();
|
|
Ok((ins.block_name.clone(), rows))
|
|
}
|
|
Some(acadrust::EntityType::Insert(_)) => {
|
|
Err("ATTEDIT This block has no attributes.")
|
|
}
|
|
_ => Err("ATTEDIT Select a block with attributes."),
|
|
};
|
|
match result {
|
|
Ok((block, rows)) => {
|
|
self.attr_editor_block = block;
|
|
self.attr_editor_rows = rows;
|
|
self.attr_editor_selected = 0;
|
|
self.attr_editor_tab = AttrTab::Attribute;
|
|
self.attr_editor_handle = Some(handle);
|
|
self.active_modal = Some(crate::app::ModalKind::AttributeEditor);
|
|
self.modal_offset = iced::Vector::ZERO;
|
|
}
|
|
Err(msg) => self.command_line.push_error(msg),
|
|
}
|
|
}
|
|
|
|
/// The currently-selected editor row, for the Text Options / Properties
|
|
/// edit handlers (which all act on that one attribute).
|
|
pub(super) fn attr_row_selected_mut(&mut self) -> Option<&mut AttrRow> {
|
|
let sel = self.attr_editor_selected;
|
|
self.attr_editor_rows.get_mut(sel)
|
|
}
|
|
|
|
/// Close the attribute editor without applying, if it is open. Used when
|
|
/// the tab it belongs to closes or the active tab switches — its handle is
|
|
/// document-local and must not outlive its tab.
|
|
pub(super) fn cancel_attr_editor(&mut self) {
|
|
if self.active_modal == Some(crate::app::ModalKind::AttributeEditor) {
|
|
self.active_modal = None;
|
|
self.modal_offset = iced::Vector::ZERO;
|
|
}
|
|
self.attr_editor_handle = None;
|
|
self.attr_editor_block.clear();
|
|
self.attr_editor_rows.clear();
|
|
self.attr_editor_selected = 0;
|
|
self.attr_editor_tab = AttrTab::Attribute;
|
|
}
|
|
|
|
/// Commit every edited attribute (value, text options, common properties)
|
|
/// back to the block, keeping the dialog open (Apply). Closing is the frame
|
|
/// ✕ (`CloseModal`), which discards any un-applied edits — matching the
|
|
/// other modal windows. Applied positionally so blocks with duplicate tags
|
|
/// stay correct; guarded on block name + count so an edit can never land on
|
|
/// a different entity if the selection changed under the open dialog.
|
|
pub(super) fn on_attr_editor_apply(&mut self) -> Task<Message> {
|
|
let i = self.active_tab;
|
|
let Some(handle) = self.attr_editor_handle else {
|
|
return Task::none();
|
|
};
|
|
// The block's layer may have been locked while the editor was open —
|
|
// refuse to write attributes to a locked-layer block.
|
|
if let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) {
|
|
self.command_line.push_info(&format!(
|
|
"Object is on locked layer \"{layer}\" — unlock the layer to edit its attributes."
|
|
));
|
|
return Task::none();
|
|
}
|
|
// Snapshot the working copy so the document can be mutated while the
|
|
// dialog's rows stay put (it remains open for further edits).
|
|
let rows = self.attr_editor_rows.clone();
|
|
let block = self.attr_editor_block.clone();
|
|
|
|
self.push_undo_snapshot(i, "ATTEDIT");
|
|
let mut changed = false;
|
|
if let Some(acadrust::EntityType::Insert(ins)) =
|
|
self.tabs[i].scene.document.get_entity_mut(handle)
|
|
{
|
|
if ins.block_name == block && rows.len() == ins.attributes.len() {
|
|
for (attr, row) in ins.attributes.iter_mut().zip(rows.iter()) {
|
|
changed |= apply_attr_row(attr, row);
|
|
}
|
|
}
|
|
}
|
|
if changed {
|
|
self.invalidate_property_targets(i, &[handle]);
|
|
self.tabs[i].dirty = true;
|
|
} else {
|
|
// Nothing changed — drop the snapshot pushed a moment ago.
|
|
let _ = self.tabs[i].history.undo_stack.pop();
|
|
}
|
|
self.refresh_properties();
|
|
Task::none()
|
|
}
|
|
}
|
|
|
|
/// Build the editor's working copy of one attribute. Angles are shown in
|
|
/// degrees; numeric fields become strings so the user can type freely.
|
|
fn attr_row_from_entity(
|
|
a: &acadrust::entities::AttributeEntity,
|
|
prompts: &rustc_hash::FxHashMap<String, String>,
|
|
) -> AttrRow {
|
|
let fmt = |v: f64| format!("{v}");
|
|
AttrRow {
|
|
tag: a.tag.clone(),
|
|
prompt: prompts.get(&a.tag).cloned().unwrap_or_default(),
|
|
value: a.get_value().to_string(),
|
|
text_style: a.text_style.clone(),
|
|
height: fmt(a.height),
|
|
rotation: fmt(a.rotation.to_degrees()),
|
|
width_factor: fmt(a.width_factor),
|
|
oblique: fmt(a.oblique_angle.to_degrees()),
|
|
h_align: a.horizontal_alignment,
|
|
v_align: a.vertical_alignment,
|
|
backwards: a.text_generation_flags & 0x2 != 0,
|
|
upside_down: a.text_generation_flags & 0x4 != 0,
|
|
layer: a.common.layer.clone(),
|
|
color: a.common.color,
|
|
linetype: a.common.linetype.clone(),
|
|
line_weight: a.common.line_weight,
|
|
}
|
|
}
|
|
|
|
/// Write one edited row back onto its attribute; returns whether anything
|
|
/// changed. Numeric fields are parsed (angles from degrees); an unparseable
|
|
/// field is left as-is.
|
|
fn apply_attr_row(a: &mut acadrust::entities::AttributeEntity, row: &AttrRow) -> bool {
|
|
let mut ch = false;
|
|
if a.get_value() != row.value {
|
|
a.set_value(row.value.clone());
|
|
ch = true;
|
|
}
|
|
if a.text_style != row.text_style {
|
|
a.text_style = row.text_style.clone();
|
|
ch = true;
|
|
}
|
|
if let Ok(h) = row.height.trim().parse::<f64>() {
|
|
if a.height != h {
|
|
a.height = h;
|
|
ch = true;
|
|
}
|
|
}
|
|
if let Ok(deg) = row.rotation.trim().parse::<f64>() {
|
|
let rad = deg.to_radians();
|
|
if (a.rotation - rad).abs() > 1e-12 {
|
|
a.rotation = rad;
|
|
ch = true;
|
|
}
|
|
}
|
|
if let Ok(w) = row.width_factor.trim().parse::<f64>() {
|
|
if a.width_factor != w {
|
|
a.width_factor = w;
|
|
ch = true;
|
|
}
|
|
}
|
|
if let Ok(deg) = row.oblique.trim().parse::<f64>() {
|
|
let rad = deg.to_radians();
|
|
if (a.oblique_angle - rad).abs() > 1e-12 {
|
|
a.oblique_angle = rad;
|
|
ch = true;
|
|
}
|
|
}
|
|
if a.horizontal_alignment != row.h_align {
|
|
a.horizontal_alignment = row.h_align;
|
|
ch = true;
|
|
}
|
|
if a.vertical_alignment != row.v_align {
|
|
a.vertical_alignment = row.v_align;
|
|
ch = true;
|
|
}
|
|
// Text generation flags: bit 0x2 = backwards, bit 0x4 = upside down.
|
|
let new_flags = (a.text_generation_flags & !0x6)
|
|
| if row.backwards { 0x2 } else { 0 }
|
|
| if row.upside_down { 0x4 } else { 0 };
|
|
if new_flags != a.text_generation_flags {
|
|
a.text_generation_flags = new_flags;
|
|
ch = true;
|
|
}
|
|
if a.common.layer != row.layer {
|
|
a.common.layer = row.layer.clone();
|
|
ch = true;
|
|
}
|
|
if a.common.color != row.color {
|
|
a.common.color = row.color;
|
|
ch = true;
|
|
}
|
|
if a.common.linetype != row.linetype {
|
|
a.common.linetype = row.linetype.clone();
|
|
ch = true;
|
|
}
|
|
if a.common.line_weight != row.line_weight {
|
|
a.common.line_weight = row.line_weight;
|
|
ch = true;
|
|
}
|
|
ch
|
|
}
|
|
|
|
/// Map each attribute tag to the prompt text declared on the block's matching
|
|
/// ATTDEF. Attribute instances (ATTRIB) carry only tag + value; the prompt is
|
|
/// defined once on the block definition. Tags with no definition are absent.
|
|
fn block_attr_prompts(
|
|
doc: &acadrust::CadDocument,
|
|
block_name: &str,
|
|
) -> rustc_hash::FxHashMap<String, String> {
|
|
let mut map = rustc_hash::FxHashMap::default();
|
|
if let Some(br) = doc.block_records.get(block_name) {
|
|
for &eh in &br.entity_handles {
|
|
if let Some(acadrust::EntityType::AttributeDefinition(ad)) = doc.get_entity(eh) {
|
|
map.insert(ad.tag.clone(), ad.prompt.clone());
|
|
}
|
|
}
|
|
}
|
|
map
|
|
}
|