use super::{ArrowKey, Message, OpenCADStudio, TextEntryMode}; use crate::command::CadCommand; use crate::scene::VIEWCUBE_DRAW_PX; use crate::ui::PropertiesPanel; use iced::time::Instant; use iced::Task; /// Keystroke-derived messages that an open modal dialog must swallow so the /// keyboard can't reach the main window (command line, F-key toggles, edit /// shortcuts) while a dialog is up. `CommandEscape` is handled separately (it /// closes the modal); a modal's own text fields emit their own messages, which /// are not in this set. See [`OpenCADStudio::update`] and #126. fn is_modal_blocked_key_msg(msg: &Message) -> bool { matches!( msg, Message::CommandInput(_) | Message::CommandAppendChar(_) | Message::CommandSpace | Message::CommandFinalize | Message::CommandBackspace | Message::CommandHistoryPrev | Message::CommandHistoryNext | Message::ArrowKeyPressed { .. } | Message::CommandLineArrowProbe { .. } | Message::CommandLineArrowResolved { .. } | Message::CommandLineSpaceProbe | Message::CommandLineSpaceResolved | Message::DynTabNext | Message::DynTabPrev | Message::MTextCaretMove(_) | Message::DeleteSelected | Message::ToggleSnapEnabled | Message::ToggleSnap3dEnabled | Message::ToggleGrid | Message::ToggleOrtho | Message::ToggleGridSnap | Message::TogglePolar | Message::ToggleOTrack | Message::ToggleDynInput | Message::ShortcutPressed(_) | Message::TabNew | Message::OpenFile | Message::SaveFile | Message::SaveAs | Message::Undo | Message::Redo | Message::FindReplaceOpen ) } fn perf_message_label(msg: &Message) -> &'static str { match msg { Message::ViewportLeftPressAt(_) | Message::ViewportLeftPress | Message::PanePress(_) => { "pointer-down" } Message::ViewportLeftRelease | Message::PaneRelease(_) => "pointer-up", Message::ViewportMove(_) | Message::PaneMove(_, _) => "pointer-move", Message::CommandFinalize => "command-finalize", Message::CommandEscape => "command-escape", Message::Undo | Message::UndoMany(_) => "undo", Message::Redo | Message::RedoMany(_) => "redo", Message::DeleteSelected => "delete", Message::HoverDwellTick => "hover-dwell", Message::InteractionIndexReady { .. } => "interaction-index-ready", _ => "other", } } const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX; fn format_size(bytes: u64) -> String { const KB: f64 = 1024.0; const MB: f64 = KB * 1024.0; const GB: f64 = MB * 1024.0; let b = bytes as f64; if b >= GB { format!("{:.2} GB", b / GB) } else if b >= MB { format!("{:.1} MB", b / MB) } else if b >= KB { format!("{:.1} KB", b / KB) } else { format!("{bytes} B") } } fn reorder_insertion_index(from: usize, to: usize, after: bool, len: usize) -> Option { if from >= len || to >= len || from == to { return None; } let mut insertion = to + usize::from(after); if from < insertion { insertion -= 1; } (insertion != from).then_some(insertion) } #[cfg(test)] mod block_ops_tests; mod blocks_palette; mod command; mod context_menu; mod dialog; mod dynamic; mod file; mod graphic_attributes; mod keytips; pub(in crate::app) use file::background_task; #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) use file::native_paths_match; mod page_setup_import; mod qselect; mod style; pub(in crate::app) mod util; mod viewport; mod viewport_snap; impl OpenCADStudio { pub(in crate::app) fn reset_modal_geometry(&mut self) { self.modal_offset = iced::Vector::ZERO; self.modal_resize = iced::Vector::ZERO; self.modal_content_size = None; self.modal_drag_last = None; self.modal_dragging = false; self.modal_resizing = false; } fn sync_open_command_history(&mut self) { if !self.command_line.history_open { return; } let latest = self.command_line.history_plain_text(); if self.history_content.text() == latest { return; } self.history_content = iced::widget::text_editor::Content::with_text(&latest); // The outer scrollable is anchored to the newest lines. Leaving the // editor cursor at its initial position also keeps horizontal scroll at // zero, so the first glyph of each line cannot be clipped. } /// Close the active in-canvas modal (Plan B), mirroring what closing the /// old OS window did: a style editor discards its staged (un-applied) /// changes, and the ribbon tool that launched the dialog is de-highlighted. pub(in crate::app) fn close_active_modal(&mut self) { self.mark_startup_modal_shown(); use super::ModalKind::*; // Plot Style opened from PLOT behaves as a child modal. // Closing it restores the parent Plot dialog instead of returning // to the drawing. // Options opened from a PDF dialog returns to it. if self.active_modal == Some(Options) { if let Some(parent) = self.options_parent.take() { self.options_saved = None; self.options_close_confirm = false; self.active_modal = Some(parent); return; } } if self.active_modal == Some(Plotstyle) { if let Some((plot_offset, plot_resize)) = self.plotstyle_parent_plot_geometry.take() { self.active_modal = Some(Plot); self.reset_modal_geometry(); self.modal_offset = plot_offset; self.modal_resize = plot_resize; return; } } if self.active_modal == Some(Plot) && self.print_all_options { if let Some(previous) = self.print_all_options_prev.take() { self.plot_dialog = previous; } if let Some(previous) = self.print_all_plot_style_prev.take() { self.active_plot_style = previous; } if let Some(previous) = self.print_all_plot_window_prev.take() { self.plot_window = previous; } if let Some(previous) = self.print_all_plot_setup_prev.take() { self.plot_setup_template = previous; } self.print_all_options = false; self.active_modal = Some(PrintAll); self.reset_modal_geometry(); return; } if matches!( self.active_modal, Some(TextStyle | DimStyle | TableStyle | MLeaderStyle | MlStyle) ) { self.style_stage_discard(); } if self.active_modal == Some(ScaleManager) { self.scale_stage_discard(); } if self.active_modal == Some(DraftingSettings) { self.snap_popup_open = false; self.drafting_settings_close_confirm = false; self.drafting_settings_state = None; self.drafting_settings_saved = None; } if self.active_modal == Some(Options) { self.options_saved = None; self.options_close_confirm = false; } #[cfg(not(target_arch = "wasm32"))] if self.active_modal == Some(FileInUse) { self.pending_save_failure = None; self.pending_close = None; } #[cfg(not(target_arch = "wasm32"))] if self.active_modal == Some(ExternalChange) { self.pending_external_change = None; self.pending_close = None; } match self.active_modal { // Dismissing these via ✕ is the cancel/decline path. Some(Unsaved) => self.pending_close = None, Some(BlockEditChanges) => self.cancel_block_edit_close(), Some(AssocPrompt) => self.mark_assoc_prompted(), // Cancel: leave the layer (and its objects) untouched. Some(LayerDeleteWarning) => self.layer_delete_pending = None, // Cancel: drop the working copy without touching the block. Some(AttributeEditor) => { self.attr_editor_handle = None; self.attr_editor_block.clear(); self.attr_editor_rows.clear(); self.attr_editor_selected = 0; self.attr_editor_tab = crate::ui::window::attribute_editor::AttrTab::Attribute; } Some(GeometricTolerance) => self.geometric_tolerance = None, Some(BlockDefinition) => self.block_definition = None, Some(BlockEditPicker) => self.block_edit_picker = None, Some(BlockSaveAs) => self.block_save_as = None, Some(PdfAttach) => self.pdf_attach = None, Some(UnderlayLayers) => self.underlay_layers = None, Some(PdfImportSettings) => self.pdf_import_settings = None, Some(PdfImportFile) => self.pdf_import_file = None, Some(XrefAttach) => self.xref_attach = None, Some(WriteBlock) => self.wblock = None, Some(Hyperlink) => { self.hyperlink_editor_handles.clear(); self.hyperlink_editor_url.clear(); self.hyperlink_editor_description.clear(); self.hyperlink_editor_mixed = false; self.hyperlink_editor_dirty = false; } // Closing (✕) discards edits made since the last Apply — matching the // style editors. Committing happens only through the Apply button. Some(Aliases) => { self.alias_editor_rows.clear(); self.alias_pending_add = false; self.alias_reset_confirm = false; self.alias_close_confirm = false; } Some(Shortcuts) => { self.shortcut_editor_rows.clear(); self.shortcut_capture_row = None; self.shortcut_pending_add = false; self.shortcut_reset_confirm = false; self.shortcut_close_confirm = false; } Some(LayerStateEditor) => { self.layer_state_edit_draft = None; self.layer_state_edit_filter.clear(); self.layer_state_edit_color_open = None; } Some(Recovery) => self.recovery_report = None, _ => {} } // The tool that opened this dialog is done with it now. Keep the // highlight only while an interactive command still runs (it owns // it). Replaces the old per-modal deactivate_tool_if list, which // missed every newly added dialog (CUI, Plugin Manager, Point // Style, Attribute Editor…). (#355) if self.tabs[self.active_tab].active_cmd.is_none() { self.ribbon.deactivate_tool(); } self.active_modal = None; // Recentre / reset the size of the next dialog and drop any drag. self.reset_modal_geometry(); } /// Fire the focus-sweep once when a property field is active, so a /// click-away that moved focus off the field (viewport, toolbar, command /// line) clears the active-row highlight. Idle (`Task::none()`) unless a /// field is active, and the sweep itself keeps the marker whenever the /// active field still holds focus — re-firing it on unrelated messages is /// cheap and safe. fn sync_active_field_if_any(&self) -> Task { if self.tabs[self.active_tab].properties.active_field.is_some() { crate::ui::properties::sync_active_field_task() } else { Task::none() } } /// Emit `SelectionChangedV4` to V4 plugins when the active tab's selection /// set actually changed since the last broadcast. #[cfg(not(target_arch = "wasm32"))] pub(super) fn notify_plugins_selection_changed(&mut self) { if self.active_tab >= self.tabs.len() { return; } let i = self.active_tab; let tab_id = self.tabs[i].id; let fingerprint = self.tabs[i].scene.selection_fingerprint(); let key = (tab_id, fingerprint); if self.last_plugin_selection == Some(key) { return; } self.last_plugin_selection = Some(key); let handles = self.tabs[i].scene.selected_handles_in_order(); crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles.clone()); // Same guarded boundary, second surface (API v9): the plain // `SelectionChanged` reaches every loaded native plugin through // `BuiltinPlugin::on_notification`, without a shared-view // subscription. crate::plugin::v4_support::publish_selection_changed(handles.clone()); self.control_record_selection_event(&handles); } /// Refresh the shared V4 document after built-in geometry edits. Plugin /// writes publish through HostSession immediately; the fingerprint avoids /// sending a duplicate notification at this message boundary. #[cfg(not(target_arch = "wasm32"))] pub(super) fn notify_plugins_document_changed(&mut self) { if self.active_tab >= self.tabs.len() { return; } let tab = &self.tabs[self.active_tab]; let key = (tab.id, tab.scene.geometry_epoch); if self.last_plugin_document == Some(key) { return; } self.last_plugin_document = Some(key); crate::plugin::v4_support::publish_drawing_changed(tab.id, tab.scene.geometry_epoch); crate::plugin::v4_support::publish_document_view_v4(tab.id, &tab.scene.document); // Same guarded boundary, second surface (API v9): the plain // `DocumentChanged` carries the settled geometry epoch to every // loaded native plugin through `BuiltinPlugin::on_notification`. crate::plugin::v4_support::publish_document_changed(tab.scene.geometry_epoch); } /// Announce that a whole document became resident in the active tab — /// opened from file, created from a template, or loaded by a system open /// (API v9). Each tab carries its own pending flag, set only when its /// content is wholesale replaced, so neither an edit nor a tab switch can /// fire it twice and a reload fires it exactly once. Part of the same /// post-message boundary as the selection and document dispatches: runs /// after the mutation settles and pending history scopes closed, never /// inside history replay or interactive picking. #[cfg(not(target_arch = "wasm32"))] pub(super) fn notify_plugins_document_opened(&mut self) { if self.active_tab >= self.tabs.len() { return; } let i = self.active_tab; if self.tabs[i].is_start { return; } if !std::mem::take(&mut self.tabs[i].document_opened_pending) { return; } crate::plugin::v4_support::publish_document_opened(self.tabs[i].id); } pub fn update(&mut self, msg: Message) -> Task { // Whatever makes another document active (switching, opening, // creating or closing a tab), the contextual ribbon tab follows that // document's selection. let before = self.tabs.get(self.active_tab).map(|tab| tab.id); let task = self.update_message(msg); if self.tabs.get(self.active_tab).map(|tab| tab.id) != before { self.sync_underlay_tab(); } self.sync_frame_dropdown(); let block_editing = self .tabs .get(self.active_tab) .is_some_and(|tab| tab.active_block_edit.is_some()); let testing = self .tabs .get(self.active_tab) .is_some_and(|tab| tab.block_test.is_some()); if testing { self.ribbon.set_block_editing(false); self.ribbon.set_block_testing(true); } else { self.ribbon.set_block_testing(false); self.ribbon.set_block_editing(block_editing); } task } /// The Frames list on the ribbon shows the active drawing's frame state /// (FRAME), however it was changed: the list, a command, undo or another /// drawing. fn sync_frame_dropdown(&mut self) { let Some(tab) = self.tabs.get(self.active_tab).filter(|tab| !tab.is_start) else { return; }; let current = match crate::scene::frame::master_mode(&tab.scene.document) { 0 => "FRAMES0", 1 => "FRAMES1", 2 => "FRAMES2", _ => "FRAMES3", }; self.ribbon.set_dropdown_current("FRAMES_DROPDOWN", current); let uosnap = match crate::scene::model::pdf_vector::uosnap() { 0 => "UOSNAP0", 1 => "UOSNAP1", _ => "UOSNAP2", }; self.ribbon.set_dropdown_current("UOSNAP_DROPDOWN", uosnap); let attmode = match tab.scene.document.header.attribute_visibility { 0 => "ATTMODE0", 2 => "ATTMODE2", _ => "ATTMODE1", }; self.ribbon .set_dropdown_current("ATTMODE_DROPDOWN", attmode); } fn update_message(&mut self, msg: Message) -> Task { if let Some(tab) = self.tabs.get(self.active_tab) { crate::entities::common::set_unit_context( crate::entities::common::UnitContext::from_header(&tab.scene.document.header), ); } self.control_observe_user_message(&msg); let perf_started = crate::perf::enabled().then(Instant::now); let perf_label = perf_message_label(&msg); // Keep the stable tab id because dispatch may switch or close tabs. let perf_edit_before = perf_started.map(|started| { let tab = &self.tabs[self.active_tab]; (started, tab.id, tab.scene.geometry_epoch) }); // The Select Color panel can open on top of another dialog, so it // takes the keyboard first: Esc cancels, Enter confirms, the arrows // walk the index palette, and every other keystroke is swallowed. if self.color_picker.is_some() { use crate::ui::color_select::ColorPickerMsg; match &msg { Message::CommandEscape => { return self.update(Message::ColorPicker(ColorPickerMsg::Cancel)); } Message::CommandFinalize => { return self.update(Message::ColorPicker(ColorPickerMsg::Ok)); } Message::ShortcutPressed(key) => match key.rsplit('+').next() { Some("ESCAPE") => { return self.update(Message::ColorPicker(ColorPickerMsg::Cancel)); } Some("ENTER" | "RETURN") => { return self.update(Message::ColorPicker(ColorPickerMsg::Ok)); } Some("TAB") => { return crate::ui::color_select::picker::focus_step( !key.contains("SHIFT+"), ); } _ => {} }, Message::ArrowKeyPressed { direction, .. } => { return self.update(Message::ColorPicker(ColorPickerMsg::Arrow(*direction))); } _ => {} } if is_modal_blocked_key_msg(&msg) { return Task::none(); } } // A modal dialog must capture the keyboard the same way it already // captures the mouse. Otherwise keystrokes from the global key // subscription leak past the modal into the command line and fire as // commands once the dialog closes. While a modal is open, Escape // closes it and every other keystroke-derived message is swallowed; // the modal's own text fields keep working because they emit their own // (non-blocked) messages. (#126) if self.active_modal.is_some() { if matches!(msg, Message::CommandEscape) || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ESCAPE")) { // 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 { return self.update(Message::AliasEditorDraftCancel); } if self.active_modal == Some(super::ModalKind::BlockDefinition) { if let Some(ref mut state) = self.block_definition { if state.confirm_redefine.is_some() { state.confirm_redefine = None; return Task::none(); } if state.error_message.is_some() { state.error_message = None; return Task::none(); } } } if self.active_modal == Some(super::ModalKind::WriteBlock) { if let Some(ref mut state) = self.wblock { if state.error_message.is_some() { state.error_message = None; return Task::none(); } } } return self.update(Message::CloseModal); } if self.active_modal == Some(super::ModalKind::BlockDefinition) { if matches!(msg, Message::CommandFinalize) || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN")) { if let Some(ref state) = self.block_definition { if state.confirm_redefine.is_some() { // Default to "No" so an accidental Enter doesn't nuke a definition. return self.update(Message::BlockDefConfirmRedefine(false)); } else { return self.update(Message::BlockDefApply); } } } } if self.active_modal == Some(super::ModalKind::BlockEditPicker) && (matches!(msg, Message::CommandFinalize) || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN"))) { return self.apply_bedit_dialog(); } if self.active_modal == Some(super::ModalKind::BlockSaveAs) && (matches!(msg, Message::CommandFinalize) || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN"))) { return self.apply_block_save_as_dialog(); } if self.active_modal == Some(super::ModalKind::WriteBlock) { if matches!(msg, Message::CommandFinalize) || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN")) { return self.update(Message::WblockApply); } } if is_modal_blocked_key_msg(&msg) { return Task::none(); } } // The open right-click context menu owns the keyboard the same way: // arrows / Enter / mnemonic letters drive it, any other key closes it // and falls through to the command line (the behaviour of commercial solutions). if self.context_menu_open() { if let Some(task) = self.intercept_context_menu_key(&msg) { return task; } } // A command's name is `&'static str`, so this runs on every message — // 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()), ) }) }; #[cfg(not(target_arch = "wasm32"))] let command_before = active_command(self); let task = self.update_inner(msg); #[cfg(not(target_arch = "wasm32"))] { let command_after = active_command(self); if command_before != command_after { if let Some((tab_id, command)) = command_after { crate::plugin::v4_support::publish_command_state_changed( tab_id, command.map(str::to_owned), ); } } } self.refresh_gpu_status(); if self.graphic_attributes.tracks_selection() { let selected = self.tabs[self.active_tab].scene.selected_handles_in_order(); self.graphic_attributes.retain_selection(&selected); } self.show_next_startup_modal(); // A Restore-all batch finished settling on this message: now that // the last restored open has inserted its tab, focus the tab the // remembered session had active (web session memory). #[cfg(target_arch = "wasm32")] if self.web_session_focus_pending { self.web_session_focus_pending = false; return self.select_remembered_session_tab(); } self.sync_open_command_history(); // Close the document-level first-touch transaction started by // push_undo_snapshot at this message boundary. self.finish_all_pending_history(); // After every message, mirror the active command step's prompt so // its history line stays pinned (non-fading) until the step changes. // A pending client pick (`user_select` / `getpoint`) pins its labeled // request line the same way while no command owns the prompt. let prompt = self.tabs[self.active_tab] .active_cmd .as_ref() .map(|c| c.prompt()) .or_else(|| self.graphic_attributes.fill_close_prompt()) .or_else(|| self.pending_pick_label()); self.command_line.set_step_prompt(prompt); // Mirror the step's clickable options so they render as buttons (#304). let opts = self.tabs[self.active_tab] .active_cmd .as_ref() .map(|c| c.options()) .or_else(|| { self.graphic_attributes .pending_fill_close .as_ref() .map(|_| { vec![ crate::command::CmdOption::new("Yes", "Y"), crate::command::CmdOption::new("No", "N"), ] }) }) .unwrap_or_default(); self.command_line.set_step_options(opts); // Persist UI preferences whenever a toggle changes them (issue #68). self.persist_settings_if_changed(); // The block panel watches the drawing's block list and rebuilds its // thumbnails whenever the names change (BLOCK define, file open, …). self.refresh_block_palette_if_stale(); // The Reference Manager watches the active drawing and re-scans when // the palette is open on another tab's entries. self.refresh_xref_manager_if_stale(); // Count mode recolours its references after the drawing changes. self.refresh_count_if_stale(); // Let V4 plugins observe selection changes that happened while handling // this message (picking, window select, QSELECT, SELECTALL, grip edits, // and plugin request draining). #[cfg(not(target_arch = "wasm32"))] self.notify_plugins_selection_changed(); #[cfg(not(target_arch = "wasm32"))] self.notify_plugins_document_changed(); // A message may also have installed a whole document into a tab (file // open, template new); announce it on the same boundary (API v9). #[cfg(not(target_arch = "wasm32"))] self.notify_plugins_document_opened(); // The `N found, M total` count belongs to the command that gathered // it; the next command, even one boxed at the same address, starts // from nothing. if self.tabs[self.active_tab].active_cmd.is_none() { self.selection_report = None; } // OTRACK acquires tracking points only while a command or grip drag is // running; drop them once neither is active so the temporary tracking // points / vectors disappear when the command ends (issue #64). A grip // drag's polar/ortho guide sets the vector with no tracking point, so // it is dropped too. (#1456) let i = self.active_tab; if self.tabs[i].active_cmd.is_none() && self.tabs[i].active_grip.is_none() && (!self.snapper.tracking_points.is_empty() || self.otrack_active.is_some()) { self.snapper.clear_tracking(); self.otrack_active = None; self.otrack_cross = None; self.otrack_kind = None; } if let Some((started, tab_id, before)) = perf_edit_before { if let Some(tab) = self.tabs.iter().find(|tab| tab.id == tab_id) { if tab.scene.geometry_epoch != before { tab.scene.record_nav_perf_caused( crate::scene::NavPerfOp::Edit, perf_label, started, ); } } } if let Some(started) = perf_started { 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,); } } #[cfg(target_arch = "wasm32")] crate::sys::set_unsaved_changes_warning( self.tabs .iter() .any(|tab| tab.dirty && tab.block_test.is_none()), ); if self.tabs[i].active_cmd.is_none() { self.block_palette.placing = None; } self.control_settle(); self.sync_spacemouse(); task } /// Drop the OTRACK acquired points and the live alignment vector once a /// point has been committed to the active command. Temporary tracking /// points are reset on every input so they don't pile up across a /// multi-point command and overwhelm the next pick (issue #85). pub(in crate::app) fn reset_tracking_after_point(&mut self) { self.snapper.clear_tracking(); self.otrack_active = None; self.otrack_cross = None; self.otrack_kind = None; } fn update_inner(&mut self, msg: Message) -> Task { match msg { Message::SpaceMouseWake => self.on_spacemouse_wake(), Message::TrackpadPinch(magnification) => self.on_pinch_zoom(magnification), Message::Touch(event) => self.on_touch(event), Message::SpaceMouseFrame(time) => { self.spacemouse .frame(time.saturating_duration_since(self.start).as_secs_f64() * 1000.); Task::none() } Message::SpaceMouseFocus(id, focused) => { if Some(id) == self.main_window { self.spacemouse_focused = focused; } // Key tips close when the window loses the focus, and an Alt // held while it left (Alt+Tab) no longer counts as a lone tap. if !focused { crate::ui::ribbon::keytip_pointer_pressed(); } if !focused && self.ribbon.keytips_active() { return self.hide_keytips(); } Task::none() } Message::SpaceMouseEnabled(enabled) => { self.spacemouse_preferences.enabled = enabled; Task::none() } Message::SpaceMouseMode(mode) => { self.spacemouse_preferences.mode = mode; Task::none() } Message::SpaceMousePanSpeed(speed) => { self.spacemouse_preferences.pan_speed = speed.clamp(10, 300); Task::none() } Message::SpaceMousePanReversed(reversed) => { self.spacemouse_preferences.pan_reversed = reversed; Task::none() } Message::SpaceMousePause => { self.spacemouse_paused = !self.spacemouse_paused; Task::none() } Message::SpaceMousePreferences => { self.open_spacemouse_preferences(); Task::none() } Message::SpaceMouseDriverSettings => self.open_spacemouse_driver_settings(), Message::SpaceMouseDetails => { self.spacemouse_details = !self.spacemouse_details; Task::none() } Message::ControlRequest(envelope) => { // Mirror the automation trail into the message list so the // person at the screen can review what clients asked and // where it failed — the JSON reply alone reaches only the // caller. High-frequency polls are traffic, not activity. let op = envelope.request["op"].as_str().unwrap_or("").to_owned(); let (response, task) = self.control_request(envelope.request); if !matches!( op.as_str(), "" | "state" | "hello" | "operation" | "events" | "capabilities" ) { 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}")); } else if matches!( response["status"].as_str(), Some("completed" | "cancelled" | "waiting_input") ) && !matches!( op.as_str(), // These already narrate themselves in richer lines. "user_select" | "getpoint" | "cancel" ) { self.command_line.push_info(&format!( "automation {op}: {}", response["status"].as_str().unwrap_or("") )); } } envelope.reply.send(response); task } #[cfg(target_arch = "wasm32")] Message::PollWebControl => { let control = if let Some(request) = super::control::web_request() { self.update(Message::ControlRequest(request)) } else { Task::none() }; // Dragged-in drawings ride the same poll (#344): start one // only while the open slot is free — the same guard as a // desktop drop — and leave the rest queued, so a multi-file // drop opens sequentially without its own subscription. let dropped = if self.opening.is_none() && self.active_modal != Some(super::ModalKind::Recovery) { crate::io::web_drop::take_dropped() .map(|file| { self.update(Message::WebFileDropped( file.name, file.bytes, file.restore_key, )) }) .unwrap_or_else(Task::none) } else { Task::none() }; // Dropped raster images register their bytes (name-keyed) and // re-resolve any RasterImage the open drawings reference but // couldn't load — the web stand-in for the drawing folder the // images live beside natively. let mut registered: Vec = Vec::new(); while let Some((name, bytes)) = crate::io::web_drop::take_dropped_image() { crate::scene::model::image_model::register_source( &name, std::sync::Arc::new(bytes), ); registered.push(name); } // Remote http(s) raster refs the background fetch settled: // successes already registered their bytes under the URL (the // fetch task does it), failures surface on the command line — // a network/CORS block, an HTTP status, the size cap, or the // deadline reads differently to the user. use crate::scene::model::image_model::{RemoteFetchError, RemoteFetchOutcome}; let mut fetched: Vec = Vec::new(); while let Some(outcome) = crate::scene::model::image_model::take_remote_fetch_outcome() { match outcome { RemoteFetchOutcome::Loaded(url) => fetched.push(url), RemoteFetchOutcome::Failed(url, reason) => { let note = match reason { RemoteFetchError::Network => crate::tf!( "Remote image \"{url}\" could not be fetched: the network request failed (offline, DNS, or CORS)." ) .into_owned(), RemoteFetchError::Status(status) => crate::tf!( "Remote image \"{url}\" could not be fetched: the server returned HTTP {status}." ) .into_owned(), RemoteFetchError::TooLarge => crate::tf!( "Remote image \"{url}\" was not loaded: it exceeds the 64 MB remote-image limit." ) .into_owned(), RemoteFetchError::Timeout => crate::tf!( "Remote image \"{url}\" could not be fetched: the request timed out." ) .into_owned(), }; self.command_line.push_error(¬e); } } } registered.extend(fetched); if crate::scene::model::image_model::take_overflowed() { // A settled fetch's rebuild trigger was dropped at the // outcome cap; do one unconditional catch-up pass over // every registered name. registered.extend(crate::scene::model::image_model::registered_image_names()); } if !registered.is_empty() { for tab in self.tabs.iter_mut() { tab.scene.resolve_registered_image_names(®istered); } } // Over-budget decode refusals from this poll's re-resolves // reach the command line; the affected images keep their // broken-reference placeholders. for note in crate::scene::model::image_model::take_decode_refusals() { self.command_line.push_error(¬e); } Task::batch([control, dropped]) } #[cfg(not(target_arch = "wasm32"))] Message::PollWebControl => Task::none(), Message::ControlStep(id, message) => self.control_step(id, *message), Message::ControlTaskDone(id) => { self.control_task_done(&id); Task::none() } Message::ControlScreenshot(path, screenshot) => { self.control_screenshot(path, screenshot); Task::none() } Message::ControlCaptureFrame { redrew } => self.on_control_capture_frame(redrew), Message::Graph(message) => self.on_graph(message), Message::ControlToggle => { self.control.enabled = !self.control.enabled; Task::none() } // Drain plugin-to-host requests that arrived outside of a host call. // This runs on a periodic timer so long-lived plugin sessions such // as the Python REPL can mutate the document without requiring a // user-generated message. Desktop only. #[cfg(not(target_arch = "wasm32"))] Message::DrainPluginRequests => { for tab in 0..self.tabs.len() { let mut host = crate::app::plugin_host::HostSession::new(self, tab); crate::plugin::external::with_manager(|mgr| { mgr.drain_requests(&mut host, &mut |_| {}); }); } crate::plugin::external::with_manager(|mgr| { for line in mgr.drain_io() { if line.text.starts_with("[runner]") || line.text.starts_with("[plugin] ") || line.text.starts_with("[python-repl]") { continue; } let prefixed = format!("[{} {}] {}", line.plugin_id, line.source, line.text); match line.source { ocs_plugin_api::process::IoStream::Stderr => { self.command_line.push_error(&prefixed); } _ => { self.command_line.push_output(&prefixed); } } } }); Task::none() } // Web: fetch every script queued by startup language selection or // drawing text discovery. Each script has one shared store entry. Message::PollWebFonts => { #[cfg(target_arch = "wasm32")] let autosave = if web_autosave_due(self.savetime_min) { self.on_autosave() } else { Task::none() }; #[cfg(not(target_arch = "wasm32"))] let autosave = Task::none(); let pending = crate::scene::text::web_font::take_pending(); if pending.is_empty() { return autosave; } Task::batch( std::iter::once(autosave).chain(pending.into_iter().map(|script| { Task::perform(crate::scene::text::web_font::fetch(script), move |res| { Message::WebFontLoaded(script, res) }) })), ) } // Web: a per-script font arrived. The same bytes feed drawing text, // the UI renderer, and the navigation-cube label atlas. Message::WebFontLoaded(script, res) => { match res { Ok(bytes) => { crate::scene::text::web_font::insert(script, Some(bytes)); crate::scene::text::ttf_glyph::clear_fallback_cache(); for tab in self.tabs.iter_mut() { tab.scene.invalidate_text_geometry_dependencies(); } return Task::done(Message::ApplyWebFont(script)); } Err(e) => { crate::scene::text::web_font::insert(script, None); self.command_line .push_error(crate::tf!("Font load failed ({script:?}): {e}").as_ref()); } } Task::none() } Message::ApplyWebFont(script) => { let Some(bytes) = crate::scene::text::web_font::loaded(script) else { return Task::none(); }; iced::font::load((*bytes).clone()).map(move |result| { Message::WebUiFontLoaded(script, result.map_err(|error| format!("{error:?}"))) }) } Message::WebUiFontLoaded(script, result) => { if let Err(error) = result { self.command_line .push_error(crate::tf!("Font load failed ({script:?}): {error}").as_ref()); return Task::none(); } if script == crate::scene::text::web_font::primary_script() { let family = iced::font::Family::name(script.family()); return iced::font::set_defaults(iced::Font::with_family(family), 16.0); } Task::none() } Message::Tick(t) => self.on_tick(t), Message::OpenFile => self.on_open_file(), Message::OpenPathPicked(None) => Task::none(), Message::OpenUrl(url) => crate::sys::open_url(&url, self.main_window), Message::StartSectionSelect(section) => { self.start_section = section; self.save_config(); Task::none() } Message::ScrollLayoutTabs(dx) => iced::widget::operation::scroll_by( iced::advanced::widget::Id::new(crate::ui::statusbar::LAYOUT_TABS_SCROLL_ID), iced::widget::scrollable::AbsoluteOffset { x: dx, y: 0.0 }, ), Message::OpenRecent(path) => { #[cfg(not(target_arch = "wasm32"))] { // Recents are read from disk every save → the path may be // stale. Skip silently if the file no longer exists; the // entry stays in the list so the user can clean it up. return match std::fs::metadata(&path) { Ok(m) => self.update(Message::OpenPathPicked(Some((path, m.len())))), Err(_) => { self.command_line.push_error( crate::tf!("Recent file no longer exists: {}", path.display()) .as_ref(), ); Task::none() } }; } #[cfg(target_arch = "wasm32")] { if let Some(idx) = self.tab_showing(&path) { return self.update(Message::TabSwitch(idx)); } let name = path .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string_lossy().into_owned()); // The start page stays clickable under the open overlay, // so a recent can race an in-flight open and clobber it // (its WebFileOpened then fails the id check and the // bytes are lost) — refuse like on_open_file does. if self.opening.is_some() { self.command_line .push_info(crate::t!("An open is already in progress.").as_ref()); return Task::none(); } let state = std::sync::Arc::new(crate::io::OpenProgressState::new( crate::app::OPEN_PHASE_READING, )); let open_id = self.next_open_id(); self.opening = Some(crate::app::OpenProgress { id: open_id, name, source_path: Some(path.clone()), size_bytes: 0, state: state.clone(), started: Instant::now(), recovery_error: None, recovery_read_stats: None, recovery_bytes: None, }); Task::perform(crate::io::open_recent_web(path, state), move |outcome| { Message::WebFileOpened(open_id, outcome) }) } } Message::OpenExternal(path) => { // A second launch forwarded this drawing. Route it through // `OpenRecent` so the redirect and a cold start share one path: // it stats the file and reports a missing one visibly, instead // of a boot that appears to do nothing. // // Raising is best-effort and cannot be made reliable from here. // `gain_focus` reaches winit's `focus_window`, which on Wayland // has an empty body — it is `request_user_attention` that walks // the xdg-activation path, and it mints its token without a seat // serial, which a compositor may refuse to honour. So expect an // attention mark rather than a raise on Wayland; X11 does raise. // A real raise needs the activation token from the launching // process, and neither iced 0.14 nor winit 0.30 can apply one to // an existing window. let raise = match self.main_window { Some(id) => Task::batch([ iced::window::gain_focus(id), iced::window::request_user_attention( id, Some(iced::window::UserAttention::Critical), ), ]), None => Task::none(), }; if self.opening.is_some() || self.active_modal == Some(super::ModalKind::Recovery) { self.pending_opens.push_back(path); raise } else if let Some(idx) = self.tab_showing(&path) { Task::batch([raise, self.update(Message::TabSwitch(idx))]) } else { Task::batch([raise, self.update(Message::OpenRecent(path))]) } } Message::WebFieldPaste => { // The MText / inline-TEXT editors have their own web paste // paths — don't double-feed them. if self.mtext_editor.is_some() || self.text_inline.is_some() { return self.on_paste_shortcut(); } #[cfg(target_arch = "wasm32")] return Task::perform( crate::sys::read_clipboard_text(), Message::WebFieldPasteText, ); #[cfg(not(target_arch = "wasm32"))] Task::none() } Message::WebFieldPasteText(text) => { #[cfg(target_arch = "wasm32")] if let Some(t) = &text { crate::sys::synthesize_typing(t); } let _ = text; Task::none() } Message::WebFieldCopy => { // Walk the widget tree for the focused text input's visible // text. iced calls `text_input` then `focusable` back-to-back // on the same widget, so remembering the last text seen pairs // it with the focus check. (An empty field reports its // placeholder — that's iced's "visible text" contract.) use iced::advanced::widget::operation::{Focusable, Outcome, TextInput}; use iced::advanced::widget::{Id, Operation}; #[derive(Default)] struct FocusedText { last_text: Option, found: Option, } impl Operation> for FocusedText { fn text_input( &mut self, _id: Option<&Id>, _bounds: iced::Rectangle, state: &mut dyn TextInput, ) { self.last_text = Some(state.text().to_owned()); } fn focusable( &mut self, _id: Option<&Id>, _bounds: iced::Rectangle, state: &mut dyn Focusable, ) { let text = self.last_text.take(); if state.is_focused() && self.found.is_none() { self.found = text; } } fn traverse( &mut self, operate: &mut dyn FnMut(&mut dyn Operation>), ) { operate(self); } fn finish(&self) -> Outcome> { Outcome::Some(self.found.clone()) } } iced::advanced::widget::operate(FocusedText::default()) .map(Message::WebFieldCopyText) } Message::WebFieldCopyText(text) => { #[cfg(target_arch = "wasm32")] if let Some(t) = &text { if !t.is_empty() { crate::sys::write_clipboard_text(t); } } let _ = text; Task::none() } Message::SnapOverridePick(t) => { self.apply_snap_override(crate::snap::SnapOverrideKind::Mode(t)); Task::none() } Message::SnapOverrideNone => { self.apply_snap_override(crate::snap::SnapOverrideKind::None); Task::none() } Message::SnapOverrideMtp => { self.snap_override_popup = None; self.tabs[self.active_tab] .scene .selection .borrow_mut() .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| { (!c.input_kind().wants_text() || c.point_step_accepts_keywords()) && !c.needs_entity_pick() }); if allowed { self.start_mtp_modifier(i); } else { self.command_line .push_info(crate::t!("MTP needs an active point prompt.").as_ref()); } Task::none() } Message::SnapOverrideClose => { self.snap_override_popup = None; Task::none() } Message::FileDropped(path) => { // Desktop drag & drop (#344): accept the formats the Open // dialog accepts — a drop has no picker filter, so anything // else reports instead of failing silently in the parser. let ext = path .extension() .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); if ext == "scr" { return self.dispatch_command(&format!("SCRIPT \"{}\"", path.display())); } if !matches!(ext.as_str(), "dwg" | "dxf" | "bak" | "sv$") { self.command_line.push_error( crate::tf!("Unsupported file type: {}", path.display()).as_ref(), ); return Task::none(); } // A load or recovery report owns the open slot; queue another // drop until that state is acknowledged. if self.opening.is_some() || self.active_modal == Some(super::ModalKind::Recovery) { self.pending_opens.push_back(path); Task::none() } else if let Some(idx) = self.tab_showing(&path) { self.update(Message::TabSwitch(idx)) } else { self.update(Message::OpenRecent(path)) } } Message::RecentRemove(path) => { self.remove_recent(&path); Task::none() } Message::SetRecentLimit(limit) => { self.set_recent_limit(limit); // Resync the input box to the clamped, applied value. self.recent_limit_input = self.recent_limit.to_string(); Task::none() } Message::SetRecentView(view) => { self.recent_view = view; self.save_config(); Task::none() } Message::RecentLimitInput(s) => { // Keep only digits while typing; applied on Enter (SetRecentLimit). self.recent_limit_input = s.chars().filter(|c| c.is_ascii_digit()).collect(); Task::none() } Message::OpenPathPicked(Some((path, size_bytes))) => { let name = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "unknown".into()); let progress = std::sync::Arc::new(crate::io::OpenProgressState::new( super::OPEN_PHASE_READING, )); let open_id = self.next_open_id(); self.opening = Some(super::OpenProgress { id: open_id, name: name.clone(), source_path: Some(path.clone()), size_bytes, state: progress.clone(), started: Instant::now(), recovery_error: None, recovery_read_stats: None, #[cfg(target_arch = "wasm32")] recovery_bytes: None, #[cfg(not(target_arch = "wasm32"))] fingerprint: crate::io::edit_lock::FileFingerprint::capture(&path).ok(), }); let size_label = format_size(size_bytes); self.command_line .push_info(crate::tf!("Opening \"{name}\" ({size_label})…").as_ref()); let model_bg = self .default_bg_color .unwrap_or_else(|| self.model_space.resolve_model_bg(&self.active_theme)); Task::perform( crate::io::open_path_with_phase(path, progress, model_bg), move |result| Message::FileOpened(open_id, result), ) } Message::OpenCancel => { #[cfg(target_arch = "wasm32")] let cancelled_id = self.opening.as_ref().map(|opening| opening.id); if let Some(p) = self.opening.take() { self.command_line .push_info(crate::tf!("Open cancelled: \"{}\"", p.name).as_ref()); } // A cancelled recovered open keeps its snapshot (re-offered // next boot — it is the only copy); the remaining queued // snapshots can still be offered this session. Match on the // open's id so cancelling an unrelated open leaves a pending // restore marker alone. #[cfg(target_arch = "wasm32")] if cancelled_id.is_some_and(|id| { self.web_autosave_consumed .as_ref() .is_some_and(|(_, recorded)| *recorded == id) }) && self.web_autosave_consumed.take().is_some() { self.queue_next_web_autosave_prompt(); } self.drain_pending_open() } #[cfg(target_arch = "wasm32")] Message::WebFileDropped(name, bytes, restore_key) => { // Bytes are already in hand (no picker round-trip), so this is // `on_open_file`'s web branch with the real name and size on // the overlay from the start. An open already in flight must // not be clobbered (its bytes are gone once dequeued), so the // entry goes back to the queue for the next poll tick. if self.opening.is_some() { // A full queue rejects the bytes here; io-side console // warning only (the drop path has no user-facing // surface for rejections by design — see QUEUE_CAP). let _ = match restore_key { Some(key) => crate::io::web_drop::enqueue_restore(name, bytes, key), None => crate::io::web_drop::enqueue_open(name, bytes), }; return Task::none(); } let state = std::sync::Arc::new(crate::io::OpenProgressState::new( crate::app::OPEN_PHASE_READING, )); let open_id = self.next_open_id(); // Identity, not display name: only this exact open may delete // the snapshot the restore came from. if let Some(key) = restore_key { self.web_autosave_consumed = Some((key, open_id)); } self.opening = Some(crate::app::OpenProgress { id: open_id, name: name.clone(), source_path: Some(std::path::PathBuf::from(&name)), size_bytes: bytes.len() as u64, state: state.clone(), started: Instant::now(), recovery_error: None, recovery_read_stats: None, recovery_bytes: None, }); Task::perform( crate::io::web_drop::open_dropped(name, bytes, state), move |outcome| Message::WebFileOpened(open_id, outcome), ) } #[cfg(target_arch = "wasm32")] Message::WebFileOpened(open_id, mut outcome) => { if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) { return Task::none(); } // A crash-recovery open settling here: success deletes its // OPFS snapshot (the only copy, kept until now); a parse // failure keeps it for the next boot. Either way the next // queued snapshot can be offered again. if self .web_autosave_consumed .as_ref() .is_some_and(|(_, recorded)| *recorded == open_id) { if outcome.result.is_ok() { self.web_autosave_opened(&outcome.name); } else { self.web_autosave_consumed = None; self.queue_next_web_autosave_prompt(); } } if let Some(opening) = self.opening.as_mut() { opening.name = outcome.name.clone(); opening.source_path = Some(std::path::PathBuf::from(&outcome.name)); if outcome.size_bytes > 0 || opening.size_bytes == 0 { opening.size_bytes = outcome.size_bytes; } opening.recovery_bytes = outcome.recovery_bytes.take(); } if let Some(bytes) = outcome.cache_bytes.take() { let name = outcome.name.clone(); return Task::perform( async move { let result = crate::io::web_recent::store_open(&name, bytes, open_id).await; (outcome, result) }, move |(outcome, result)| Message::WebFileCached(open_id, outcome, result), ); } let recent_task = if outcome.record_recent && outcome.result.is_ok() { self.push_recent(std::path::PathBuf::from(&outcome.name)) } else { Task::none() }; let opened_task = self.update(Message::FileOpened(open_id, outcome.result)); Task::batch([recent_task, opened_task]) } #[cfg(target_arch = "wasm32")] Message::WebFileCached(open_id, outcome, cache_result) => { if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) { return Task::none(); } let recent_task = match cache_result { Ok(()) => self.push_recent(std::path::PathBuf::from(&outcome.name)), Err(error) => { self.command_line.push_error( crate::tf!( "Opened drawing, but recent copy could not be stored: {error}" ) .as_ref(), ); Task::none() } }; let opened_task = self.update(Message::FileOpened(open_id, outcome.result)); Task::batch([recent_task, opened_task]) } Message::FileOpened(open_id, Ok((name, path, doc, caches))) => { if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) { return Task::none(); } let task = self.on_file_opened(name, path, doc, caches); // Web: raster files dropped while this open was in flight // registered after its caches were built — re-resolve any // image the (possibly new) drawing references by name. #[cfg(target_arch = "wasm32")] { let names = crate::scene::model::image_model::registered_image_names(); if !names.is_empty() { for tab in self.tabs.iter_mut() { tab.scene.resolve_registered_image_names(&names); } } } // Images in (or dropped alongside) the opened drawing that // exceeded the decode budget refused with a note; surface // them now that the open has settled — the entities // themselves show the broken-reference placeholder. for note in crate::scene::model::image_model::take_decode_refusals() { self.command_line.push_error(¬e); } task } Message::FileOpened(open_id, Err(e)) => { if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) { return Task::none(); } if e.recovery_available { if let Some(opening) = self.opening.as_mut() { opening.recovery_error = Some(e.message); opening.recovery_read_stats = e.read_stats; self.active_modal = Some(super::ModalKind::RecoveryPrompt); return Task::none(); } } // If the user cancelled, the overlay was already cleared and // we suppress the noise. let opening = self.opening.take(); if let Some(opening) = opening.filter(|_| e.message != "Cancelled") { self.command_line .push_error(crate::tf!("Open failed: {e}").as_ref()); let total_ms = opening.started.elapsed().as_millis() as u32; let failure_phase = crate::io::open_phase_name( opening .state .phase .load(std::sync::atomic::Ordering::Acquire), ) .to_string(); let mut report = crate::io::recovery::RecoveryReport::failed( opening.source_path, opening.name, opening.size_bytes, e.source_sha256, e.read_stats, failure_phase, e.message, total_ms, ); report.persist(); self.recovery_report = Some(report); self.active_modal = Some(super::ModalKind::Recovery); return Task::none(); } // A drawing that fails to parse must not strand the ones queued // behind it. self.drain_pending_open() } #[cfg(target_arch = "wasm32")] Message::WebRecentStored(result) => match result { Ok(path) => self.push_recent(path), Err(error) => { self.command_line.push_error( crate::tf!("Saved download, but recent copy could not be stored: {error}") .as_ref(), ); Task::none() } }, // The `?file=` boot deep link settled. Queued bytes are drained by // the control poll's drop queue like any drop — nothing to say // until that open starts (or its parse error surfaces there). #[cfg(target_arch = "wasm32")] Message::WebLinkSettled(outcome) => { use crate::io::web_link::LinkOutcome; match outcome { LinkOutcome::Queued => {} LinkOutcome::Info(text) => self.command_line.push_info(&text), LinkOutcome::Error(text) => self.command_line.push_error(&text), } Task::none() } #[cfg(target_arch = "wasm32")] Message::WebAutosavesScanned(result) => self.on_web_autosaves_scanned(result), #[cfg(target_arch = "wasm32")] Message::WebAutosaveRestore => self.on_web_autosave_restore(), #[cfg(target_arch = "wasm32")] Message::WebAutosaveRestoreAll => self.on_web_autosave_restore_all(), #[cfg(target_arch = "wasm32")] Message::WebAutosaveDiscard => self.on_web_autosave_discard(), #[cfg(target_arch = "wasm32")] Message::WebAutosaveDefer => self.on_web_autosave_defer(), #[cfg(target_arch = "wasm32")] Message::WebAutosaveBytes(name, key, result) => { self.on_web_autosave_bytes(name, key, result) } #[cfg(target_arch = "wasm32")] Message::WebAutosaveSnapshotFinished(result) => self.on_web_autosave_finished(result), Message::ImagePick => { Task::perform(crate::io::pick_image_file(), Message::ImagePickResult) } Message::ImagePickResult(Ok((path, pw, ph))) => { use crate::command::CadCommand; use crate::modules::draw::draw::raster_image::ImageCommand; let path_str = path.to_string_lossy().into_owned(); let short = std::path::Path::new(&path_str) .file_name() .and_then(|n| n.to_str()) .unwrap_or(&path_str) .to_string(); self.command_line .push_output(crate::tf!("IMAGE \"{short}\": {pw}×{ph} px").as_ref()); let cmd = ImageCommand::new(path_str, pw, ph); let i = self.active_tab; self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); Task::none() } Message::ImagePickResult(Err(e)) => { if e != "Cancelled" { self.command_line .push_error(crate::tf!("IMAGE: {e}").as_ref()); } Task::none() } 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(), ); let cmd = ImageCommand::new_embedded(image); let i = self.active_tab; self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); Task::none() } Message::ImageEmbedPickResult(Err(e)) => { if e != "Cancelled" { self.command_line .push_error(crate::tf!("IMAGEEMBED: {e}").as_ref()); } Task::none() } Message::MissingFontsSourceChanged(url) => { self.font_source_input = url; Task::none() } Message::MissingFontsDownload => { if self.missing_fonts_downloading { return Task::none(); } // Remember the source across sessions — and across drawings. let source = self.font_source_input.trim().to_string(); if self.font_source_url != source { self.font_source_url = source.clone(); self.save_config(); } let fonts = self.missing_fonts.clone().unwrap_or_default(); if fonts.is_empty() { return Task::none(); } self.missing_fonts_downloading = true; Task::perform( async move { let source = crate::io::font_repo::FontSource::from_url(&source); crate::io::font_repo::download_fonts(&fonts, &source) }, Message::MissingFontsResult, ) } Message::MissingFontsDismiss => { if self.missing_fonts_downloading { return Task::none(); } for name in self.missing_fonts.take().unwrap_or_default() { self.suppressed_missing_fonts .insert(crate::io::font_repo::font_key(&name)); } self.missing_fonts_path = None; self.close_active_modal(); Task::none() } Message::MissingFontsResult(result) => { self.missing_fonts_downloading = false; match result { Ok(pairs) if pairs.is_empty() => { let unavailable = self.missing_fonts.take().unwrap_or_default(); for name in &unavailable { self.suppressed_missing_fonts .insert(crate::io::font_repo::font_key(name)); } self.missing_fonts_path = None; self.close_active_modal(); self.command_line.push_error(crate::t!( "None of the requested fonts are available from the selected source. Substitute fonts remain active; this notice will not repeat during this session." ).as_ref()); } Ok(pairs) => { let requested = self.missing_fonts.take().unwrap_or_default(); let downloaded: rustc_hash::FxHashSet = pairs .iter() .map(|(name, _)| crate::io::font_repo::font_key(name)) .collect(); let unavailable: Vec = requested .into_iter() .filter(|name| { !downloaded.contains(&crate::io::font_repo::font_key(name)) }) .collect(); for name in &unavailable { self.suppressed_missing_fonts .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(), ); } if !unavailable.is_empty() { self.command_line.push_info(crate::tf!( "FONT Not available from the selected source: {fonts}. Substitute fonts remain active.", fonts = unavailable.join(", ") ).as_ref()); } // The downloaded files change glyph resolution for the // whole drawing — reload it through the standard open // pipeline so every wire is rebuilt with the real fonts. let path = self.missing_fonts_path.take(); self.close_active_modal(); 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(), ); } 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()); } } Task::none() } Message::PdfAttachPick => Task::perform( async { let handle = crate::sys::file_dialog() .set_title(crate::t!("Select PDF Underlay").as_ref()) .add_filter(crate::t!("PDF Files").as_ref(), &["pdf", "PDF"]) .pick_file() .await; match handle { Some(h) => { let path = crate::sys::handle_path(&h); let bytes = std::sync::Arc::new(h.read().await); Ok((path, bytes)) } None => Err("Cancelled".to_string()), } }, Message::PdfAttachPickResult, ), Message::PdfAttachPickResult(Ok((path, bytes))) => { let path_str = path.to_string_lossy().into_owned(); crate::scene::model::pdf_raster::register_source(&path_str, bytes); // Pages, placement and path type are chosen in the Attach // dialog; the definition is created with the underlay. self.open_pdf_attach_dialog(&path_str); Task::none() } Message::PdfImportPick => Task::perform( async { let handle = crate::sys::file_dialog() .set_title(crate::t!("Select PDF File").as_ref()) .add_filter(crate::t!("PDF Files").as_ref(), &["pdf", "PDF"]) .pick_file() .await; match handle { Some(h) => { let path = crate::sys::handle_path(&h); let bytes = std::sync::Arc::new(h.read().await); Ok((path, bytes)) } None => Err("Cancelled".to_string()), } }, Message::PdfImportPickResult, ), Message::PdfImportPickResult(Ok((path, bytes))) => { let path_str = path.to_string_lossy().into_owned(); crate::scene::model::pdf_raster::register_source(&path_str, bytes); self.open_pdf_import_file(&path_str); Task::none() } Message::PdfImportPickResult(Err(_)) => Task::none(), Message::PdfAttachPickResult(Err(e)) => { if e != "Cancelled" { self.command_line .push_error(crate::tf!("PDFATTACH: {e}").as_ref()); } Task::none() } Message::XAttachPick => { // Web: the browser hands back bytes, not a path — read them, // register them name-keyed (so reloads and re-opens of the // host drawing re-resolve the xref in-session) and pre-parse // on the worker in one step. The returned name-only PathBuf // is what the native branch's `handle_path` yields there. #[cfg(target_arch = "wasm32")] { Task::perform( crate::io::pick_and_register_xref_web(), Message::XAttachPickResult, ) } #[cfg(not(target_arch = "wasm32"))] { Task::perform( async { let handle = crate::sys::file_dialog() .set_title(crate::t!("Select External Reference File").as_ref()) .add_filter( crate::t!("CAD Files").as_ref(), &["dwg", "dxf", "bak", "DWG", "DXF", "BAK"], ) .add_filter(crate::t!("DWG Files").as_ref(), &["dwg", "DWG"]) .add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"]) .add_filter(crate::t!("Backup Files").as_ref(), &["bak", "BAK"]) .pick_file() .await; match handle { Some(h) => Ok(crate::sys::handle_path(&h)), None => Err("Cancelled".to_string()), } }, Message::XAttachPickResult, ) } } Message::XAttachPickResult(Ok(path)) => { self.open_xref_attach_dialog(path); Task::none() } message @ (Message::AttachPick | Message::UnderlayAttachPick(_) | Message::PointCloudAttachPick | Message::AttachPickResult(_) | Message::XrefAttach(_) | Message::XrefAttachBrowseResult(_)) => self.update_xref_attach(message), Message::XAttachPickResult(Err(e)) => { if e != "Cancelled" { self.command_line .push_error(crate::tf!("XATTACH: {e}").as_ref()); } Task::none() } Message::WblockSave(block_name) => { let name = block_name.clone(); let title = if block_name == "-" { crate::t!("Create Drawing File").into_owned() } else { crate::t!("Save Block As").into_owned() }; Task::perform( async move { let path = crate::sys::file_dialog() .set_title(&title) .set_file_name("block.dwg") .add_filter(crate::t!("DWG Files").as_ref(), &["dwg"]) .save_file() .await .map(|h| crate::sys::handle_path(&h)); (name, path) }, |(name, path)| Message::WblockSaveResult(name, path), ) } // -WBLOCK with FILEDIA 1: the file came from the dialog, the // command line asks for the rest. Message::WblockSaveResult(block_name, Some(path)) if block_name == "-" => { self.dispatch_command(&format!("_-WBLOCKFILE {}", path.display())) } Message::WblockSaveResult(block_name, Some(path)) => { self.on_wblock_save_result_some(block_name, path) } Message::WblockSaveResult(_, None) => Task::none(), Message::WblockWriteFinished(block_name, path, result) => { // A written file is not reported (as in the reference); a // failed write is. let _ = (&block_name, &path); if let Err(error) = result { self.command_line .push_error(crate::tf!("WBLOCK save failed: {error}").as_ref()); } Task::none() } // Web WBLOCK completion: the download is the only artifact of // the save — fire it even if the source tab was closed while // serialization ran — then reuse the WblockWriteFinished // bookkeeping for the command-line report. #[cfg(target_arch = "wasm32")] Message::WebWblockSerialized { block_name, path, filename, result, } => { if let Ok(bytes) = &result { crate::sys::download_bytes(&filename, bytes); } self.update(Message::WblockWriteFinished( block_name, path, result.map(|_| ()), )) } Message::TableInsertStyle(value) => self.on_table_insert_style(value), Message::TableInsertField(field) => self.on_table_insert_field(field), Message::TableInsertApply => self.on_table_insert_apply(), Message::DataLinkManagerOpen => { let from_table = self.active_modal == Some(super::ModalKind::InsertTable); self.open_data_link_manager(from_table); Task::none() } Message::DataLinkNew => self.on_data_link_new(), Message::DataLinkSelect(handle) => self.on_data_link_select(handle), Message::DataLinkEdit => self.on_data_link_edit(), Message::DataLinkEditCancel => { self.data_link_manager.editing = false; self.data_link_manager.editing_handle = None; self.data_link_manager.status.clear(); Task::none() } Message::DataLinkField(field) => self.on_data_link_field(field), Message::DataLinkBrowse => self.on_data_link_browse(), Message::DataLinkBrowseResult(path) => self.on_data_link_browse_result(path), Message::DataLinkSave => self.on_data_link_save(), Message::DataLinkDelete => self.on_data_link_delete(), Message::DataLinkInsert => self.on_data_link_insert(), Message::DataLinkClose => self.on_data_link_close(), Message::DataExtractionOpen => { self.open_data_extraction(); Task::none() } Message::DataExtractionField(field) => self.on_data_extraction_field(field), Message::DataExtractionBack => self.on_data_extraction_back(), Message::DataExtractionNext => self.on_data_extraction_next(), Message::DataExtractionBrowseSettings => self.on_data_extraction_browse_settings(), Message::DataExtractionBrowseSettingsResult(path) => { self.on_data_extraction_browse_settings_result(path) } Message::DataExtractionAddDrawings => self.on_data_extraction_add_drawings(), Message::DataExtractionAddDrawingsResult(Ok(paths)) => { self.on_data_extraction_add_drawings_result(paths) } Message::DataExtractionAddDrawingsResult(Err(error)) => { self.data_extraction.error = error; Task::none() } Message::DataExtractionAddFolder => self.on_data_extraction_add_folder(), Message::DataExtractionAddFolderResult(path) => { self.on_data_extraction_add_folder_result(path) } Message::DataExtractionClearSources => { self.data_extraction.source_files.clear(); Task::none() } Message::DataExtractionBrowseOutput => self.on_data_extraction_browse_output(), Message::DataExtractionBrowseOutputResult(path) => { self.on_data_extraction_browse_output_result(path) } Message::DataExtractionFinish => self.on_data_extraction_finish(), Message::DataExtractionSave(csv) => { let csv_clone = csv.clone(); Task::perform( async move { let path = crate::sys::file_dialog() .set_title(crate::t!("Save Data Extraction").as_ref()) .set_file_name("extraction.csv") .add_filter(crate::t!("CSV").as_ref(), &["csv"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .save_file() .await .map(|h| crate::sys::handle_path(&h)); (csv_clone, path) }, |(csv, path)| Message::DataExtractionSaveResult(csv, path), ) } Message::DataExtractionSaveResult(csv, Some(path)) => { let fname = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string_lossy().into_owned()); // The browser has no writable path behind the picked name: // the CSV leaves as a download instead of an fs::write. #[cfg(target_arch = "wasm32")] let write_result: Result<(), String> = { crate::sys::download_bytes(&fname, csv.as_bytes()); Ok(()) }; #[cfg(not(target_arch = "wasm32"))] let write_result = std::fs::write(&path, csv.as_bytes()).map_err(|e| e.to_string()); match write_result { Ok(()) => { let rows = csv.lines().count().saturating_sub(1); self.command_line.push_output( crate::tf!("DATAEXTRACTION {rows} rows → \"{fname}\"").as_ref(), ); } Err(e) => self .command_line .push_error(crate::tf!("DATAEXTRACTION: write failed: {e}").as_ref()), } Task::none() } Message::DataExtractionSaveResult(_, None) => Task::none(), Message::StlExport => { let i = self.active_tab; if self.tabs[i].scene.meshes.is_empty() { self.command_line .push_error(crate::t!("STLOUT: no 3D mesh data in this drawing.").as_ref()); return Task::none(); } #[cfg(not(target_arch = "wasm32"))] { Task::perform( async { crate::sys::file_dialog() .set_title(crate::t!("Export STL").as_ref()) .set_file_name("export.stl") .add_filter(crate::t!("STL Files").as_ref(), &["stl"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .save_file() .await .map(|h| crate::sys::handle_path(&h)) }, Message::StlExportPath, ) } // Web: rfd's save dialog shows no UI, so skip straight to a // virtual path — the browser download supplies the file name. #[cfg(target_arch = "wasm32")] { let stem = self.tabs[i] .current_path .as_deref() .and_then(|p: &std::path::Path| p.file_stem()) .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".into()); Task::perform( crate::io::stl::pick_stl_path_owned(stem), Message::StlExportPath, ) } } Message::StlExportPath(Some(path)) => self.on_stl_export_path_some(path), Message::StlExportPath(None) => Task::none(), Message::StlExportFinished(path, result) => { match result { Ok(()) => self.command_line.push_output( crate::tf!("STLOUT: exported to \"{}\"", path.display()).as_ref(), ), Err(error) => self .command_line .push_error(crate::tf!("STLOUT: {error}").as_ref()), } Task::none() } // ── STEP AP203 export ───────────────────────────────────────── Message::StepExport => { let i = self.active_tab; if self.tabs[i].scene.meshes.is_empty() { self.command_line.push_error( crate::t!("STEPOUT: no 3D mesh data in this drawing.").as_ref(), ); return Task::none(); } #[cfg(not(target_arch = "wasm32"))] { Task::perform( async { crate::sys::file_dialog() .set_title(crate::t!("Export STEP AP203").as_ref()) .set_file_name("export.step") .add_filter(crate::t!("STEP Files").as_ref(), &["step", "stp"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .save_file() .await .map(|h| crate::sys::handle_path(&h)) }, Message::StepExportPath, ) } // Web: rfd's save dialog shows no UI, so skip straight to a // virtual path — the browser download supplies the file name. #[cfg(target_arch = "wasm32")] { let stem = self.tabs[i] .current_path .as_deref() .and_then(|p: &std::path::Path| p.file_stem()) .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".into()); Task::perform( crate::io::step::pick_step_path_owned(stem), Message::StepExportPath, ) } } // ── FBX export ──────────────────────────────────────────────── Message::FbxExport => { let i = self.active_tab; if self.tabs[i].scene.meshes.is_empty() { self.command_line.push_error( crate::t!("FBXEXPORT: no 3D mesh data in this drawing.").as_ref(), ); return Task::none(); } #[cfg(not(target_arch = "wasm32"))] { Task::perform( async { crate::sys::file_dialog() .set_title(crate::t!("Export FBX").as_ref()) .set_file_name("export.fbx") .add_filter(crate::t!("FBX Files").as_ref(), &["fbx"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .save_file() .await .map(|h| crate::sys::handle_path(&h)) }, Message::FbxExportPath, ) } #[cfg(target_arch = "wasm32")] { let stem = self.tabs[i] .current_path .as_deref() .and_then(|p: &std::path::Path| p.file_stem()) .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".into()); Task::perform( crate::io::fbx::pick_fbx_path_owned(stem), Message::FbxExportPath, ) } } Message::FbxExportPath(Some(path)) => self.on_fbx_export_path_some(path), Message::FbxExportPath(None) => Task::none(), Message::FbxExportFinished(path, result) => { match result { Ok(()) => self.command_line.push_output( crate::tf!("FBXEXPORT: exported to \"{}\"", path.display()).as_ref(), ), Err(error) => self .command_line .push_error(crate::tf!("FBXEXPORT: {error}").as_ref()), } Task::none() } Message::StepExportPath(Some(path)) => self.on_step_export_path_some(path), Message::StepExportPath(None) => Task::none(), Message::StepExportFinished(path, result) => { match result { Ok(()) => self.command_line.push_output( crate::tf!("STEPOUT: exported to \"{}\"", path.display()).as_ref(), ), Err(error) => self .command_line .push_error(crate::tf!("STEPOUT: {error}").as_ref()), } Task::none() } // ── OBJ import ──────────────────────────────────────────────── #[cfg(not(target_arch = "wasm32"))] Message::ObjImport => Task::perform( async { crate::sys::file_dialog() .set_title(crate::t!("Import OBJ Mesh").as_ref()) .add_filter(crate::t!("Wavefront OBJ").as_ref(), &["obj", "OBJ"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .pick_file() .await .map(|h| crate::sys::handle_path(&h)) }, Message::ObjImportPath, ), // Web: the browser picker hands back a handle whose bytes must be // read directly — the name-only `PathBuf` it maps to cannot be // opened — so pick, read, and parse in one step and feed the // shared finished handler. A cancelled picker stays silent. #[cfg(target_arch = "wasm32")] Message::ObjImport => { let tab_id = self.tabs[self.active_tab].id; Task::perform( async move { let handle = crate::sys::file_dialog() .set_title(crate::t!("Import OBJ Mesh").as_ref()) .add_filter(crate::t!("Wavefront OBJ").as_ref(), &["obj", "OBJ"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .pick_file() .await?; let path = crate::sys::handle_path(&handle); let bytes = handle.read().await; let result = crate::io::obj::parse_obj( &String::from_utf8_lossy(&bytes), [0.7, 0.7, 0.85, 1.0], ) .ok_or_else(|| "no usable geometry in file".to_string()); Some((tab_id, path, result)) }, |picked| match picked { Some((tab_id, path, result)) => { Message::ObjImportFinished(tab_id, path, result) } None => Message::Noop, }, ) } Message::ObjImportPath(Some(path)) => self.on_obj_import_path_some(path), Message::ObjImportPath(None) => Task::none(), Message::ObjImportFinished(tab_id, path, result) => { match result { Err(error) => self .command_line .push_error(crate::tf!("IMPORTOBJ: {error}").as_ref()), Ok(mut mesh) => { let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else { self.command_line.push_info( crate::t!("IMPORTOBJ: target drawing was closed.").as_ref(), ); return Task::none(); }; let file_stem = path .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "obj_mesh".into()); mesh.name = file_stem.clone(); self.push_undo_snapshot(i, "IMPORTOBJ"); let entity = crate::modules::insert::solid3d_cmds::empty_solid3d(); let handle = self.tabs[i].scene.add_entity(entity); if !handle.is_null() { self.tabs[i] .scene .meshes .insert(handle, crate::scene::MeshLodSet::from_single(mesh)); self.tabs[i].dirty = true; self.command_line.push_output( crate::tf!("IMPORTOBJ: imported \"{file_stem}\" as mesh.").as_ref(), ); } } } Task::none() } Message::SaveFile => self.on_save_file(), Message::SaveAs => { if self.read_only { self.command_line.push_error( crate::t!("Read-only session (--read-only): saving is disabled.").as_ref(), ); return Task::none(); } let i = self.active_tab; self.save_dialog_for_unsaved = false; self.open_save_dialog_window(i) } Message::SaveDialogFormatChanged(fmt) => { let (ext, _) = crate::io::parse_save_format(&fmt); let stem = std::path::Path::new(&self.save_dialog_filename) .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".to_string()); self.save_dialog_filename = format!("{stem}.{ext}"); self.save_dialog_format = fmt; Task::none() } Message::SaveDialogFilenameChanged(name) => { self.save_dialog_filename = name; Task::none() } Message::SaveDialogConfirm => self.on_save_dialog_confirm(), Message::SaveDialogCancel => self.close_save_dialog_window(), #[cfg(not(target_arch = "wasm32"))] Message::SaveDialogPathPicked(picked) => self.on_save_dialog_path_picked(picked), #[cfg(target_arch = "wasm32")] Message::SaveDialogPathPicked(_) => Task::none(), Message::ClearScene => { let i = self.active_tab; self.push_undo_snapshot(i, "CLEAR"); self.tabs[i].scene.reset_to_new_drawing(); self.tabs[i].properties = PropertiesPanel::empty(); 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.command_line .push_output(crate::t!("Scene cleared. Standard linetypes loaded.").as_ref()); self.tabs[i].current_path = None; self.tabs[i].dirty = true; self.sync_ribbon_layers(); Task::none() } Message::SetRenderMode(mode) => { self.render_mode_menu_open = false; self.render_mode_preview = None; self.on_set_render_mode(mode) } Message::ToggleRenderModeMenu(mode) => { self.render_mode_menu_open = !self.render_mode_menu_open; self.render_mode_preview = self.render_mode_menu_open.then_some(mode); Task::none() } Message::DismissRenderModeMenu => { self.render_mode_menu_open = false; self.render_mode_preview = None; Task::none() } Message::PreviewRenderMode(mode) => { if self.render_mode_menu_open { self.render_mode_preview = Some(mode); } Task::none() } Message::SetProjection(ortho) => { use crate::scene::Projection; let proj = if ortho { Projection::Orthographic } else { Projection::Perspective }; let i = self.active_tab; self.tabs[i].scene.set_projection_preserving_frame(proj); self.ribbon.set_ortho(ortho); self.command_line.push_output( crate::t!(if ortho { "Projection: Orthographic" } else { "Projection: Perspective" }) .as_ref(), ); Task::none() } Message::PdfDialog(message) => self.update_pdf_dialog(message), Message::AttdefDialog(message) => self.on_attdef_dialog(message), Message::FieldDialog(message) => self.on_field_dialog(message), Message::RefEditDialog(message) => self.update_refedit_dialog(message), Message::PcManager(message) => self.update_pc_manager(message), Message::Count(message) => self.on_count(message), Message::SheetSet(message) => self.on_sheet_set(message), Message::KeyTip(input) => self.on_keytip(input), Message::RibbonSelectTab(idx) => { self.ribbon.select(idx); Task::none() } Message::SetRibbonCollapseMode(mode) => { self.ribbon.set_collapse_mode(mode); self.ribbon.close_dropdown(); self.save_config(); Task::none() } Message::RibbonToolClick { tool_id, event } => { let sweep = self.sync_active_field_if_any(); Task::batch(vec![sweep, self.on_ribbon_tool_click(tool_id, event)]) } Message::PluginFileDialogResult { command, path } => { if let Some(path) = path { // Dispatch " " with original case intact — // the command line would upper-case the whole string and // mangle case-sensitive paths on Linux/macOS. let line = format!("{} {}", command, path.to_string_lossy()); let i = self.active_tab; if !crate::plugin::try_dispatch(self, i, &line) { self.command_line .push_error(crate::tf!("No plugin handled: {command}").as_ref()); } } Task::none() } // ── Document tabs ───────────────────────────────────────────── Message::TabNew => { // Preserve the outgoing drawing's Ortho / running OSNAP. self.stamp_header_sysvars(self.active_tab); self.tab_counter += 1; let new_tab = super::document::DocumentTab::new_drawing(self.tab_counter); self.tabs.push(new_tab); self.active_tab = self.tabs.len() - 1; let idx = self.active_tab; // A fresh drawing inherits the app's current Ortho / OSNAP so // creating one doesn't silently reset them (its header default is // 0 = no snaps); saving then persists them into the file. self.stamp_header_sysvars(idx); self.apply_display_defaults(idx); self.sync_ribbon_layers(); self.sync_ribbon_styles(); // #21: reset ribbon Color / Linetype / Lineweight to the // fresh tab's defaults (ByLayer) instead of inheriting the // previous tab's last selection. self.sync_ribbon_from_selection(); // A fresh drawing starts with grid/snap off (its tile defaults). self.adopt_view_display(self.active_tab); // QNEW: start from the default template when one is set (#1113). let template = self.qnew_template.trim().to_string(); if !template.is_empty() { match self.apply_template(&template) { Ok(_) => { self.tabs[idx].dirty = false; self.refresh_layer_panel(); self.sync_ribbon_styles(); } Err(error) => self .command_line .push_error(error["error"].as_str().unwrap_or("template")), } } Task::none() } Message::TabSwitch(idx) => { if self.active_modal == Some(super::ModalKind::Recovery) { return Task::none(); } self.layout_list_open = false; self.layout_rename_state = None; if idx < self.tabs.len() { if idx != self.active_tab { // The attribute editor is tab-scoped; leaving its tab // drops it (its handle is that document's, not this one's). self.cancel_attr_editor(); // Persist the outgoing drawing's Ortho / running OSNAP // before leaving it, so switching back restores them. let prev = self.active_tab; self.stamp_header_sysvars(prev); } self.active_tab = idx; if self.tabs[idx].is_start { self.ribbon.close_dropdown(); } if self.tabs[idx].is_start && matches!( self.active_modal, Some( super::ModalKind::LayoutManager | super::ModalKind::LayerStateManager | super::ModalKind::LayerStateEditor ) ) { self.close_active_modal(); } else if self.active_modal == Some(super::ModalKind::LayerStateEditor) { self.close_active_modal(); } else if self.active_modal == Some(super::ModalKind::LayerStateManager) { let mut names: Vec = self.tabs[idx] .scene .document .layer_states() .into_iter() .map(|state| state.name) .collect(); names.sort_by_key(|name| name.to_lowercase()); self.load_layer_state_editor(names.into_iter().next()); } self.sync_ribbon_layers(); self.sync_ribbon_styles(); // #21: also re-seed ribbon Color / Linetype / Lineweight // from the newly active tab so they reflect that doc's // CECOLOR / CELTYPE / CELWEIGHT (or its current selection // if there is one), not the prior tab's choice. self.sync_ribbon_from_selection(); // Grid/snap follow the newly active drawing's viewport. self.adopt_view_display(idx); // Ortho / running OSNAP follow the newly active drawing. self.adopt_header_sysvars(idx); // Shared CJK ideographs follow the newly active drawing's // language; re-tessellate if it differs from the last. (#141) if crate::scene::text::web_font::set_cjk_lang_from_codepage( &self.tabs[idx].scene.document.header.code_page, ) { crate::scene::text::ttf_glyph::clear_fallback_cache(); self.tabs[idx].scene.invalidate_text_geometry_dependencies(); } } Task::none() } Message::DocTabHover(index) => { self.hovered_doc_tab = index.filter(|&idx| idx < self.tabs.len()); Task::none() } Message::TabReorder { from, to, after } => { let Some(insertion) = reorder_insertion_index(from, to, after, self.tabs.len()) else { return Task::none(); }; if self.tabs.get(from).is_some_and(|tab| tab.is_start) || self.tabs.get(to).is_some_and(|tab| tab.is_start) { return Task::none(); } let active_id = self.tabs[self.active_tab].id; let moved = self.tabs.remove(from); self.tabs.insert(insertion, moved); if let Some(index) = self.tabs.iter().position(|tab| tab.id == active_id) { self.active_tab = index; } self.hovered_doc_tab = None; Task::none() } Message::TabClose(tab_id) => { self.hovered_doc_tab = None; // Resolve the stable id when the message is handled, not when // it was built: an earlier close in the same batch may have // shifted or removed the tab a captured index referred to. match self.tabs.iter().position(|tab| tab.id == tab_id) { Some(idx) => self.on_tab_close(idx), // Already closed by an earlier message — nothing to do. None => Task::none(), } } Message::DocTabSaveAll => self.dispatch_command("SAVEALL"), // Both close-* variants resolve ids against the live tab list at // handling time (like `TabClose` above), so a batch that closed // tabs earlier cannot leave them stale. Message::DocTabCloseAll => { let ids = self .tabs .iter() .filter(|tab| !tab.is_start) .map(|tab| tab.id) .collect(); self.begin_tab_close_queue(ids) } Message::DocTabCloseOthers(idx) => { let Some(keep_id) = self.tabs.get(idx).filter(|tab| !tab.is_start).map(|t| t.id) else { return Task::none(); }; let switch = self.update(Message::TabSwitch(idx)); let ids = self .tabs .iter() .filter(|tab| !tab.is_start && tab.id != keep_id) .map(|tab| tab.id) .collect(); Task::batch([switch, self.begin_tab_close_queue(ids)]) } Message::DocTabCopyFullPath(idx) => { #[cfg(not(target_arch = "wasm32"))] { let Some(path) = self.tabs.get(idx).and_then(|tab| tab.current_path.clone()) else { self.command_line.push_error( crate::t!("Save the drawing before copying its file path.").as_ref(), ); return Task::none(); }; let full_path = path.canonicalize().unwrap_or_else(|_| { if path.is_absolute() { path } else { std::env::current_dir() .map(|dir| dir.join(&path)) .unwrap_or(path) } }); self.command_line .push_output(crate::tf!("Copied path: {}", full_path.display()).as_ref()); return iced::clipboard::write(full_path.to_string_lossy().into_owned()) .discard(); } #[cfg(target_arch = "wasm32")] { let _ = idx; self.command_line.push_error( crate::t!("Full file paths are unavailable in the web application.") .as_ref(), ); Task::none() } } Message::DocTabOpenFileLocation(idx) => { #[cfg(not(target_arch = "wasm32"))] { let Some(path) = self.tabs.get(idx).and_then(|tab| tab.current_path.clone()) else { self.command_line.push_error( crate::t!("Save the drawing before opening its file location.") .as_ref(), ); return Task::none(); }; match crate::sys::reveal_in_file_manager(&path) { Ok(()) => self.command_line.push_output( crate::tf!("Opened file location: {}", path.display()).as_ref(), ), Err(error) => self.command_line.push_error( crate::tf!("Could not open file location: {error}").as_ref(), ), } Task::none() } #[cfg(target_arch = "wasm32")] { let _ = idx; self.command_line.push_error( crate::t!("File locations are unavailable in the web application.") .as_ref(), ); Task::none() } } Message::CommandInput(s) => { // Space submits (acts like Enter) so a command advances // token-by-token, matching CAD convention. A leading `>` switches // to literal-space mode so an argument containing spaces (a text // string, a path, `UCS Z 90` as one line) can be typed; the `>` // is stripped on submit. (Unfocused Space repeats the last // command via CommandSpace.) // Command-line entry is shown uppercase — except while the // active command collects free-form text (cell text, TEXT / // MTEXT bodies), where the typed case is the content and // Space must stay in the buffer. let text_with_spaces = self.is_free_text_active(); let literal = self.command_line.literal_spaces || s.starts_with('>'); let s = if text_with_spaces || literal { s } else { s.to_uppercase() }; // A space submits (acts like Enter). The whole value is handed to // the submit path, which tokenises multi-token lines — so a typed // token, a pasted `LINE 0,0 10,10`, or API-fed text all run their // spaces as step separators. A leading `>` keeps spaces literal, // as does an active free-form text prompt. let sweep = self.sync_active_field_if_any(); if !text_with_spaces && !self.command_line.literal_spaces && !s.starts_with('>') && s.contains(' ') { self.command_line.input = s; return Task::batch(vec![sweep, self.on_command_submit()]); } let live_input = s.clone(); self.command_line.input = live_input.clone(); let i = self.active_tab; if self.dyn_input && self.tabs[i] .active_grip .as_ref() .is_some_and(|grip| grip.mode.uses_scalar_dynamic_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 = (!live_input.is_empty()).then_some(live_input.clone()); } self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); // Live incremental search for INSERT/MINSERT: update picker on each keystroke // without requiring Enter. Performance-first: uses upper/lower caches // and partial sort (O(k)) so per-keystroke is <0.2ms even for 10k blocks. // Suggestion count is in direct relation to CLIPROMPTLINES (picker limit). { 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, suggestions) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { 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(), suggestions) } } else { (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 // (avoids flooding history with one entry per keystroke). if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } } } Task::batch(vec![sweep, Task::none()]) } Message::CommandAppendChar(s) => self.on_command_append_char(s), Message::CommandBackspace => self.on_command_backspace(), Message::DynTabNext if self.grip_popup.is_some() => { if let Some(popup) = self.grip_popup.as_mut() { if !popup.items.is_empty() { popup.selected = (popup.selected + 1) % popup.items.len(); } } Task::none() } 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 { self.tabs[i].dyn_active = (self.tabs[i].dyn_active + 1) % n; // TAB locks the value just typed — reshape the rubber-band // to the constrained point now (#356). self.refresh_active_cmd_preview(i); } 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); self.tabs[i].scene.camera_generation += 1; Task::none() } Message::CloseModelViewport => { let i = self.active_tab; self.tabs[i].scene.close_active_pane(); self.tabs[i].scene.camera_generation += 1; self.sync_render_mode_to_active_tile(i); self.adopt_view_display(i); Task::none() } Message::CommandHistoryPrev => { if self.tabs[self.active_tab] .properties .hatch_pattern_picker_open { return self.update(Message::PropHatchPatternNavigate(-2)); } // Grip popup wins first — arrow keys walk its items. if let Some(popup) = self.grip_popup.as_mut() { if !popup.items.is_empty() { popup.selected = if popup.selected == 0 { popup.items.len() - 1 } else { popup.selected - 1 }; } return Task::none(); } let i = self.active_tab; if !self.command_line.history_navigation_active() && (self.tabs[i].active_cmd.is_none() || self.command_line.has_contextual_suggestions()) && self.command_line.autocomplete_prev() { return Task::none(); } self.command_line.history_prev(); iced::widget::operation::move_cursor_to_end(iced::widget::Id::new( crate::ui::command_line::CMD_INPUT_ID, )) } Message::CommandHistoryNext => { if self.tabs[self.active_tab] .properties .hatch_pattern_picker_open { return self.update(Message::PropHatchPatternNavigate(2)); } if let Some(popup) = self.grip_popup.as_mut() { if !popup.items.is_empty() { popup.selected = (popup.selected + 1) % popup.items.len(); } return Task::none(); } let i = self.active_tab; if !self.command_line.history_navigation_active() && (self.tabs[i].active_cmd.is_none() || self.command_line.has_contextual_suggestions()) && self.command_line.autocomplete_next() { return Task::none(); } self.command_line.history_next(); iced::widget::operation::move_cursor_to_end(iced::widget::Id::new( crate::ui::command_line::CMD_INPUT_ID, )) } Message::CommandLineSpaceProbe => iced::widget::operation::is_focused( iced::widget::Id::new(crate::ui::command_line::CMD_INPUT_ID), ) .map(|focused| { if focused { Message::CommandLineSpaceResolved } else { Message::Noop } }), Message::CommandLineSpaceResolved => { if self.command_line.input.ends_with(' ') { self.command_line.input.pop(); } self.update(Message::ShortcutPressed("SPACE".to_string())) } Message::CommandLineArrowProbe { direction, extend_selection, } => iced::widget::operation::is_focused(iced::widget::Id::new( crate::ui::command_line::CMD_INPUT_ID, )) .map(move |focused| Message::CommandLineArrowResolved { direction, focused, extend_selection, }), Message::CommandLineArrowResolved { direction, focused, extend_selection, } => { if !focused { return Task::none(); } // The editor inherits arrows captured by the focused command line. if self.mtext_editor.is_some() { match direction { ArrowKey::Up => self.mtext_caret_move_vertical(1, extend_selection), ArrowKey::Down => self.mtext_caret_move_vertical(-1, extend_selection), ArrowKey::Left | ArrowKey::Right => {} } return Task::none(); } match direction { ArrowKey::Up => self.update(Message::CommandHistoryPrev), ArrowKey::Down => self.update(Message::CommandHistoryNext), ArrowKey::Left | ArrowKey::Right => Task::none(), } } Message::ArrowKeyPressed { direction, shortcut, extend_selection, } => { if self.mtext_editor.is_some() { match direction { ArrowKey::Left => self.mtext_caret_move(-1, extend_selection), ArrowKey::Right => self.mtext_caret_move(1, extend_selection), ArrowKey::Up => self.mtext_caret_move_vertical(1, extend_selection), ArrowKey::Down => self.mtext_caret_move_vertical(-1, extend_selection), } Task::none() } else { match direction { ArrowKey::Up => self.update(Message::CommandHistoryPrev), ArrowKey::Down => self.update(Message::CommandHistoryNext), ArrowKey::Left | ArrowKey::Right => self.run_shortcut(&shortcut), } } } Message::CommandLiteralToggle => { self.command_line.literal_spaces = !self.command_line.literal_spaces; self.save_config(); self.focus_cmd_input() } Message::CommandHistoryHover(hovered) => { self.command_line.set_history_hover(hovered); Task::none() } Message::CommandHistoryToggle => { self.command_line.toggle_history(); if !self.command_line.history_open { self.command_history_resizing = false; self.command_history_drag_last = None; } self.sync_open_command_history(); Task::none() } Message::CommandHistoryResizeGrab => { if self.command_line.history_open { self.command_history_resizing = true; self.command_history_drag_last = None; } Task::none() } Message::CommandHistoryResizeMove(point) => { if self.command_history_resizing { if let Some(last) = self.command_history_drag_last { let dy = point.y - last.y; let max_height = crate::ui::command_line::history_max_height(self.win_size.1); self.command_line.history_height = (self.command_line.history_height - dy) .clamp(crate::ui::command_line::HISTORY_HEIGHT_MIN, max_height); } self.command_history_drag_last = Some(point); } Task::none() } Message::CommandHistoryResizeRelease => { let changed = self.command_history_resizing; self.command_history_resizing = false; self.command_history_drag_last = None; if changed { self.save_config(); } Task::none() } Message::CommandHistoryHeightReset => { self.command_line.history_height = crate::ui::command_line::HISTORY_HEIGHT_DEFAULT; self.save_config(); Task::none() } Message::CommandHistoryCopy => { let text = self.command_line.history_plain_text(); if text.is_empty() { return Task::none(); } #[cfg(target_arch = "wasm32")] { let promise = crate::sys::copy_history_text( &text, crate::t!("Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history.").as_ref(), crate::t!("Close").as_ref(), ); Task::perform( async move { wasm_bindgen_futures::JsFuture::from(promise) .await .ok() .and_then(|value| value.as_bool()) .unwrap_or(false) }, Message::CommandHistoryCopied, ) } #[cfg(not(target_arch = "wasm32"))] iced::clipboard::write(text).discard() } #[cfg(target_arch = "wasm32")] Message::CommandHistoryCopied(copied) => { if copied { self.command_line.push_info(crate::t!("Copied").as_ref()); } else { self.command_line.push_error(crate::t!("Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history.").as_ref()); } Task::none() } Message::CommandHistoryClear => { self.command_line.clear_history(); self.history_content = iced::widget::text_editor::Content::new(); Task::none() } Message::PerfCopy => { let text = crate::perf::snapshot_text(); if text.is_empty() { Task::none() } else { iced::clipboard::write(text).discard() } } Message::PerfClear => { crate::perf::clear(); Task::none() } Message::CommandHistoryEdit(action) => { // Read-only: drop edits, keep selection/cursor actions, and // route wheel input to the outer scrollbar. The text editor // remains one selectable buffer while the scrollbar stays // visible and draggable. if let iced::widget::text_editor::Action::Scroll { lines } = action { iced::widget::operation::scroll_by( iced::widget::Id::new(crate::ui::command_line::HISTORY_SCROLL_ID), iced::widget::scrollable::AbsoluteOffset { x: 0.0, y: lines as f32 * 14.0, }, ) } else { if !action.is_edit() { self.history_content.perform(action); } Task::none() } } 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.dispatch_command(&value) } Message::CommandOptionPick(kw) => { // Clicking an option button feeds its keyword to the active // command through the same path as typed text; an empty keyword // finishes the step like Enter. (#304) self.command_line.input.clear(); self.command_line.close_history(); if self.awaiting_fill_close_answer() { return self.on_graphic_fill_close_input(&kw); } if kw.is_empty() { return self.feed_command(crate::command::StepInput::Enter); } self.feed_active_cmd(&kw) } Message::CommandSubmit => self.on_command_submit(), Message::CommandSpace => { // What Space means is decided in one place: the MText preview // and free-form text prompts take it as a literal character, // everything else finalises the active command like Enter. match self.text_entry_mode() { TextEntryMode::MTextPreview => { self.mtext_type(" "); Task::none() } // A free-form text prompt (table cell content, TEXT / // MTEXT bodies) keeps Space in the buffer as a literal // character. TextEntryMode::FreeText => { self.command_line.input.push(' '); Task::none() } TextEntryMode::Command => { // A leading `>` (or the persistent `>` toggle) puts // the command line in "literal space" mode so the user // can type arguments that contain spaces. The typed // `>` is stripped on submit. if self.command_line.literal_spaces || self.command_line.input.starts_with('>') { self.command_line.input.push(' '); return Task::none(); } self.update(Message::CommandFinalize) } } } Message::CommandFinalize => self.on_command_finalize(), Message::CommandEscape => { let panel = &mut self.tabs[self.active_tab].properties; if panel.hatch_pattern_picker_open { panel.hatch_pattern_picker_open = false; panel.hatch_pattern_search.clear(); panel.hatch_pattern_focus = 0; Task::none() } else { self.on_command_escape() } } Message::Command(cmd) => { // Close viewport context menu if open. let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().menu.open_at = None; // Any command also dismisses the Isolate action menu. self.isolate_popup_open = false; // "Pick window" (PLOTWINDOW) from Page Setup needs the backdrop // gone so the viewport pick lands; every other command leaves an // open modal (and its staged edits) untouched. if cmd.trim().eq_ignore_ascii_case("PLOTWINDOW") || cmd.trim().eq_ignore_ascii_case("PW") { self.close_active_modal(); } self.dispatch_command(&cmd) } Message::ScriptLine(line) => self.feed_script_line(&line), // Web SCRIPT: the picked .scr file arrived — run it through the // same runner the native path feeds. #[cfg(target_arch = "wasm32")] Message::ScriptFilePicked(Some((name, text))) => self.run_script(&name, &text), #[cfg(target_arch = "wasm32")] Message::ScriptFilePicked(None) => Task::none(), // Web DATALINK: the picked .csv's bytes arrived — register them // as the link's in-session source, then run the same table-insert // path the native typed-path command feeds. #[cfg(target_arch = "wasm32")] Message::DataLinkCsvPicked(picked) => self.on_datalink_csv_picked(picked), // Web CENTERLTYPEFILE: the picked .lin/.dnl file's text arrived — // load its linetypes, then record the setting under the file's // name (the native command reads the typed path inside // `set_centerline_setting` instead). #[cfg(target_arch = "wasm32")] Message::CenterlineLinetypeFilePicked(Some((name, source))) => { let i = self.active_tab; crate::io::linetypes::populate_document_from_source( &mut self.tabs[i].scene.document, &source, ); match self.tabs[i] .scene .set_centerline_setting("CENTERLTYPEFILE", &name) { Ok(message) => { self.tabs[i].dirty = true; self.command_line.push_output(&message); } Err(error) => self.command_line.push_error(&error), } Task::none() } #[cfg(target_arch = "wasm32")] Message::CenterlineLinetypeFilePicked(None) => Task::none(), Message::ToggleLayers => { if self.show_layers { return self.on_dock(crate::ui::dock::DockMsg::Close( crate::ui::dock::PanelId::Layers, )); } self.open_layers_pallet(); Task::none() } Message::ToggleXrefManager => { self.show_external_references ^= true; if self.show_external_references { self.dock_open_at_default(crate::ui::dock::PanelId::ExternalReferences); self.refresh_xref_manager(); } Task::none() } Message::XrefManagerRefresh => { self.refresh_xref_manager(); Task::none() } 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 // selects one row, Ctrl/Cmd toggles, Shift extends a range. let extend = if self.ctrl_down { SelectExtend::Toggle } else if self.shift_down { SelectExtend::Range } else { SelectExtend::Single }; self.xref_manager.click_select(&id, extend); Task::none() } Message::XrefRowRightClick(id) => { self.xref_manager.row_change_path_open = false; self.xref_manager.right_click_select(&id); Task::none() } Message::XrefManagerToggleTree => { self.xref_manager.toggle_tree(); Task::none() } Message::XrefColGrab(i) => { // Start a table-column divider drag; moves arrive via the // header's mouse_area (XrefColMove), like the Layers // Name-column divider rides ModalDragMove. self.xref_col_drag = Some(i); self.xref_col_last = None; Task::none() } Message::XrefColMove(p) => { if let Some(i) = self.xref_col_drag { if let Some(last) = self.xref_col_last { self.xref_manager.drag_col_by(i, p.x - last.x); } self.xref_col_last = Some(p); } Task::none() } Message::XrefColRelease => { self.xref_col_drag = None; self.xref_col_last = None; Task::none() } Message::XrefSplitGrab => { self.xref_split_drag = true; self.xref_col_last = None; Task::none() } Message::XrefSplitMove(p) => { if self.xref_split_drag { if let Some(last) = self.xref_col_last { self.xref_manager.drag_table_by(p.y - last.y); } self.xref_col_last = Some(p); } Task::none() } Message::XrefSplitRelease => { self.xref_split_drag = false; self.xref_col_last = None; Task::none() } Message::XrefManagerTogglePreview => { self.xref_manager.show_preview ^= true; Task::none() } Message::XrefManagerAttachMenu => { self.xref_manager.attach_open ^= true; self.xref_manager.refresh_open = false; self.xref_manager.path_open = false; Task::none() } Message::XrefManagerRefreshMenu => { self.xref_manager.refresh_open ^= true; self.xref_manager.attach_open = false; self.xref_manager.path_open = false; Task::none() } Message::XrefManagerPathMenu => { self.xref_manager.path_open ^= true; self.xref_manager.attach_open = false; self.xref_manager.refresh_open = false; Task::none() } Message::XrefHelpOpen => { self.active_modal = Some(super::ModalKind::XrefHelp); Task::none() } Message::XrefManagerDismissMenus => { self.xref_manager.attach_open = false; self.xref_manager.refresh_open = false; self.xref_manager.path_open = false; self.xref_manager.row_change_path_open = false; Task::none() } Message::XrefPathPick => Task::perform( async { let handle = crate::sys::file_dialog() .set_title(crate::t!("Select New Path").as_ref()) .pick_file() .await; match handle { Some(h) => Ok(crate::sys::handle_path(&h)), None => Err("Cancelled".to_string()), } }, Message::XrefPathPickResult, ), Message::XrefPathPickResult(Ok(path)) => { // Select New Path applies the picked file to the single // direct anchor entry (menu gates the rest). let i = self.active_tab; let anchor = self.xref_manager.anchor.and_then(|a| { self.xref_manager .entries .get(a) .filter(|_| !self.xref_manager.nested.contains(&a)) .map(|e| (e.key, e.name.clone())) }); let Some((key, _)) = anchor else { return Task::none(); }; 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) { Ok(name) => { 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()), } self.refresh_xref_manager(); Task::none() } Message::XrefPathPickResult(Err(e)) => { if e != "Cancelled" { self.command_line .push_error(crate::tf!("XREF: {e}").as_ref()); } Task::none() } Message::XrefManagerReloadAll => { self.xref_manager_reload_all(); Task::none() } Message::XrefManagerToggleExpand(key) => { self.xref_manager.toggle_expand(key); Task::none() } Message::XrefManagerOp(op) => { self.xref_manager_op(op); Task::none() } 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(&id); self.xref_manager.row_change_path_open = false; 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 { 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(&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(&id); self.xref_manager.row_change_path_open = false; self.xref_manager_op(op); Task::none() } Message::XrefRowPathPick(id) => { self.xref_manager.right_click_select(&id); self.xref_manager.row_change_path_open = false; self.update(Message::XrefPathPick) } Message::XrefRowFindReplacePrompt(id) => { self.xref_manager.right_click_select(&id); self.xref_manager.row_change_path_open = false; 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() }; self.command_line.input = prefill; self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); self.command_line.push_info( crate::t!("Specify replacement path: XREF Path Find ").as_ref(), ); return self.focus_cmd_input(); } Message::XrefRowChangePathEnter => { self.xref_manager.row_change_path_open = true; Task::none() } Message::XrefRowChangePathLeave => { self.xref_manager.row_change_path_open = false; Task::none() } Message::XrefFindReplacePrompt => { // No fields in the panel: prefill the command line so the // existing `XREF Path Find ` parsing runs it. self.command_line.input = "XREF Path Find ".to_string(); self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); return self.focus_cmd_input(); } Message::LayerStateManagerOpen => { let i = self.active_tab; self.ribbon.close_dropdown(); if self.tabs[i].is_start { self.command_line.push_info( crate::t!("Open or create a drawing to manage layer states.").as_ref(), ); return Task::none(); } let mut names: Vec = self.tabs[i] .scene .document .layer_states() .into_iter() .map(|state| state.name) .collect(); names.sort_by_key(|name| name.to_lowercase()); self.load_layer_state_editor(names.into_iter().next()); self.active_modal = Some(super::ModalKind::LayerStateManager); Task::none() } // ── Layer Translator (#624) ────────────────────────────────── Message::LayerTranslatorLoad => { #[cfg(not(target_arch = "wasm32"))] { Task::perform(crate::io::pick_layer_standard_path(), |path| match path { Some(path) => Message::LayerTranslatorLoaded(path), None => Message::Noop, }) } // Web: read the picked file's bytes and parse them on the // worker right away — a name-only path has nothing behind it // for `load_targets` to read later. #[cfg(target_arch = "wasm32")] { Task::perform( async { crate::io::pick_and_parse_web( crate::t!("Load layer standard").as_ref(), crate::t!("Drawings and standards").as_ref(), &["dwg", "dws", "dwt", "dxf", "DWG", "DWS", "DWT", "DXF"], ) .await .and_then(|picked| { picked.map_or(Ok(None), |(name, document)| { crate::modules::draw::layers::laytrans::targets_from_document( &document, ) .map(|targets| Some((name, targets))) }) }) }, |loaded| Message::LayerTranslatorLoadedWeb(loaded), ) } } #[cfg(target_arch = "wasm32")] Message::LayerTranslatorLoadedWeb(loaded) => { match loaded { Ok(Some((source_file, targets))) => { let state = self.layer_translator.get_or_insert_with(Default::default); state.source_file = source_file; state.targets = targets; // A target set that no longer contains a mapped name // would translate onto nothing. let names: Vec = state.targets.iter().map(|t| t.name.clone()).collect(); state .mappings .retain(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.to))); state.selected_to = None; } Err(why) => self .command_line .push_error(crate::tf!("LAYTRANS: {why}.").as_ref()), Ok(None) => {} } Task::none() } Message::LayerTranslatorLoaded(path) => { use crate::modules::draw::layers::laytrans; match laytrans::load_targets(&path) { Ok(targets) => { let state = self.layer_translator.get_or_insert_with(Default::default); state.source_file = path .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_default(); state.targets = targets; // A target set that no longer contains a mapped name // would translate onto nothing. let names: Vec = state.targets.iter().map(|t| t.name.clone()).collect(); state .mappings .retain(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.to))); state.selected_to = None; } Err(why) => self .command_line .push_error(crate::tf!("LAYTRANS: {why}.").as_ref()), } Task::none() } Message::LayerTranslatorSelectFrom(name) => { if let Some(state) = self.layer_translator.as_mut() { state.selected_from = Some(name); } Task::none() } Message::LayerTranslatorSelectTo(name) => { if let Some(state) = self.layer_translator.as_mut() { state.selected_to = Some(name); } Task::none() } Message::LayerTranslatorMap => { use crate::modules::draw::layers::laytrans::Mapping; if let Some(state) = self.layer_translator.as_mut() { if let (Some(from), Some(to)) = (state.selected_from.take(), state.selected_to.clone()) { state .mappings .retain(|m| !m.from.eq_ignore_ascii_case(&from)); state.mappings.push(Mapping { from, to }); } } Task::none() } Message::LayerTranslatorMapSame => { use crate::modules::draw::layers::laytrans; let i = self.active_tab; let current = self.tabs[i].active_layer.clone(); let sources = laytrans::source_layers(&self.tabs[i].scene, ¤t); if let Some(state) = self.layer_translator.as_mut() { for mapping in laytrans::map_same(&sources, &state.targets) { if !state .mappings .iter() .any(|m| m.from.eq_ignore_ascii_case(&mapping.from)) { state.mappings.push(mapping); } } } Task::none() } Message::LayerTranslatorUnmap(from) => { if let Some(state) = self.layer_translator.as_mut() { state.mappings.retain(|m| m.from != from); } Task::none() } Message::LayerTranslatorForceByLayer(value) => { if let Some(state) = self.layer_translator.as_mut() { state.force_bylayer = value; } Task::none() } Message::LayerTranslatorWriteLog(value) => { if let Some(state) = self.layer_translator.as_mut() { state.write_log = value; } Task::none() } Message::LayerTranslatorSaveMappings => Task::perform( crate::io::pick_layer_mapping_path(true), |path| match path { Some(path) => Message::LayerTranslatorMappingsPath(path, true), None => Message::Noop, }, ), Message::LayerTranslatorLoadMappings => Task::perform( crate::io::pick_layer_mapping_path(false), |path| match path { Some(path) => Message::LayerTranslatorMappingsPath(path, false), None => Message::Noop, }, ), Message::LayerTranslatorMappingsPath(path, save) => { self.layer_translator_mappings_file(&path, save); Task::none() } Message::LayerTranslatorTranslate => { use crate::modules::draw::layers::laytrans; let Some(state) = self.layer_translator.take() else { return Task::none(); }; self.active_modal = None; let i = self.active_tab; let current = self.tabs[i].active_layer.clone(); self.push_undo_snapshot(i, "LAYTRANS"); let report = laytrans::translate( &mut self.tabs[i].scene, &state.mappings, &state.targets, ¤t, laytrans::Options { force_bylayer: state.force_bylayer, }, ); let write_log = state.write_log; let task = self.finish_layer_translation(i, report); if write_log { self.write_layer_translation_log(i); } task } Message::LayerStateManagerSelect(name) => { self.load_layer_state_editor(Some(name)); Task::none() } Message::LayerStateManagerNew => { self.load_layer_state_editor(None); Task::none() } Message::LayerStateManagerFilter(value) => { self.layer_state_filter = value; Task::none() } Message::LayerStateManagerName(value) => { self.layer_state_name_buf = value; Task::none() } Message::LayerStateManagerDescription(value) => { self.layer_state_description_buf = value; Task::none() } Message::LayerStateManagerSave => { let i = self.active_tab; let name = self.layer_state_name_buf.trim().to_string(); if name.is_empty() { self.command_line .push_error(crate::t!("Layer state name cannot be empty.").as_ref()); return Task::none(); } let old_name = self.layer_state_selected.clone(); let duplicate = self.tabs[i] .scene .document .layer_states() .into_iter() .any(|state| { state.name.eq_ignore_ascii_case(&name) && old_name .as_deref() .is_none_or(|old| !state.name.eq_ignore_ascii_case(old)) }); if duplicate { self.command_line .push_error(crate::tf!("Layer state \"{name}\" already exists.").as_ref()); return Task::none(); } self.push_undo_snapshot(i, "LAYERSTATE SAVE"); if let Some(old_name) = old_name.as_deref() { if !old_name.eq_ignore_ascii_case(&name) { self.tabs[i] .scene .document .rename_layer_state(old_name, &name); } } self.tabs[i] .scene .document .capture_layer_state(&name, self.layer_state_description_buf.trim()); self.tabs[i].dirty = true; self.layer_state_selected = Some(name.clone()); self.layer_state_name_buf = name.clone(); self.command_line.push_output( crate::tf!("LAYERSTATE: saved \"{name}\" in the drawing.").as_ref(), ); Task::none() } Message::LayerStateManagerRestore => { let i = self.active_tab; let Some(name) = self.layer_state_selected.clone() else { return Task::none(); }; let layer_names: Vec = self.tabs[i] .scene .document .layers .iter() .map(|layer| layer.name.clone()) .collect(); self.push_undo_snapshot(i, "LAYERSTATE RESTORE"); let restored = self.restore_layer_state_here(i, &name); let active = self.tabs[i] .scene .document .header .current_layer_name .clone(); self.tabs[i].active_layer = active; self.tabs[i] .scene .invalidate_layer_dependencies(&layer_names); self.tabs[i].dirty = true; self.refresh_layer_panel(); self.command_line.push_output( crate::tf!("LAYERSTATE: restored \"{name}\" ({restored} layer(s)).").as_ref(), ); Task::none() } Message::LayerStateManagerDelete => { let i = self.active_tab; let Some(name) = self.layer_state_selected.clone() else { return Task::none(); }; self.push_undo_snapshot(i, "LAYERSTATE DELETE"); if self.tabs[i].scene.document.delete_layer_state(&name) { self.tabs[i].dirty = true; let mut names: Vec = self.tabs[i] .scene .document .layer_states() .into_iter() .map(|state| state.name) .collect(); names.sort_by_key(|name| name.to_lowercase()); self.load_layer_state_editor(names.into_iter().next()); self.command_line .push_output(crate::tf!("LAYERSTATE: deleted \"{name}\".").as_ref()); } Task::none() } Message::LayerStateManagerEdit => { let i = self.active_tab; let Some(name) = self.layer_state_selected.clone() else { return Task::none(); }; let Some(state) = self.tabs[i].scene.document.layer_state(&name) else { self.command_line .push_error(crate::tf!("Layer state \"{name}\" was not found.").as_ref()); return Task::none(); }; self.layer_state_edit_draft = Some(state); self.layer_state_edit_filter.clear(); self.layer_state_edit_color_open = None; self.active_modal = Some(super::ModalKind::LayerStateEditor); Task::none() } Message::LayerStateEditorMaskToggle(property) => { let flag = match property { super::LayerStateProperty::On => codec::LayerStateMask::ON, 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::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, }; if let Some(state) = self.layer_state_edit_draft.as_mut() { state.mask = codec::LayerStateMask::from_bits(state.mask.bits() ^ flag.bits()); } Task::none() } Message::LayerStateEditorLayerFlagToggle(index, flag) => { let Some(layer) = self .layer_state_edit_draft .as_mut() .and_then(|state| state.layers.get_mut(index)) else { return Task::none(); }; match flag { super::LayerStateLayerFlag::On => layer.off = !layer.off, super::LayerStateLayerFlag::Frozen => layer.frozen = !layer.frozen, super::LayerStateLayerFlag::Locked => layer.locked = !layer.locked, super::LayerStateLayerFlag::Plot => layer.plottable = !layer.plottable, super::LayerStateLayerFlag::NewViewport => { layer.new_viewport_frozen = !layer.new_viewport_frozen } } Task::none() } Message::LayerStateEditorLayerColorToggle(index) => { self.layer_state_edit_color_open = if self.layer_state_edit_color_open == Some(index) { None } else { Some(index) }; Task::none() } Message::LayerStateEditorLayerColor(index, color) => { if let Some(layer) = self .layer_state_edit_draft .as_mut() .and_then(|state| state.layers.get_mut(index)) { layer.color = color; } self.layer_state_edit_color_open = None; Task::none() } Message::LayerStateEditorLayerLinetype(index, value) => { if let Some(layer) = self .layer_state_edit_draft .as_mut() .and_then(|state| state.layers.get_mut(index)) { layer.line_type = value; } Task::none() } Message::LayerStateEditorLayerLineweight(index, value) => { if let Some(layer) = self .layer_state_edit_draft .as_mut() .and_then(|state| state.layers.get_mut(index)) { layer.line_weight = value; } Task::none() } Message::LayerStateEditorLayerPlotStyle(index, value) => { if let Some(layer) = self .layer_state_edit_draft .as_mut() .and_then(|state| state.layers.get_mut(index)) { layer.plot_style = value; } Task::none() } Message::LayerStateEditorLayerTransparency(index, value) => { if let Some(layer) = self .layer_state_edit_draft .as_mut() .and_then(|state| state.layers.get_mut(index)) { layer.transparency = value; } Task::none() } Message::LayerStateEditorName(value) => { if let Some(state) = self.layer_state_edit_draft.as_mut() { state.name = value; } Task::none() } Message::LayerStateEditorDescription(value) => { if let Some(state) = self.layer_state_edit_draft.as_mut() { state.description = value; } Task::none() } Message::LayerStateEditorCurrentLayer(value) => { if let Some(state) = self.layer_state_edit_draft.as_mut() { state.current_layer = value; } Task::none() } Message::LayerStateEditorFilter(value) => { self.layer_state_edit_filter = value; Task::none() } Message::LayerStateEditorSave => { let i = self.active_tab; let Some(draft) = self.layer_state_edit_draft.as_ref() else { return Task::none(); }; let name = draft.name.trim().to_string(); if name.is_empty() { self.command_line .push_error(crate::t!("Layer state name cannot be empty.").as_ref()); return Task::none(); } let old_name = self.layer_state_selected.clone(); let duplicate = self.tabs[i] .scene .document .layer_states() .into_iter() .any(|state| { state.name.eq_ignore_ascii_case(&name) && old_name .as_deref() .is_none_or(|old| !state.name.eq_ignore_ascii_case(old)) }); if duplicate { self.command_line .push_error(crate::tf!("Layer state \"{name}\" already exists.").as_ref()); return Task::none(); } let Some(state) = self.layer_state_edit_draft.take() else { return Task::none(); }; let mut state = state; state.name.clone_from(&name); state.description = state.description.trim().to_string(); let description = state.description.clone(); self.push_undo_snapshot(i, "LAYERSTATE EDIT"); if let Some(old_name) = old_name.as_deref() { if !old_name.eq_ignore_ascii_case(&name) { self.tabs[i] .scene .document .rename_layer_state(old_name, &name); } } self.tabs[i].scene.document.store_layer_state(state); self.tabs[i].dirty = true; self.layer_state_selected = Some(name.clone()); self.layer_state_name_buf = name.clone(); self.layer_state_description_buf = description; self.layer_state_edit_filter.clear(); self.layer_state_edit_color_open = None; self.active_modal = Some(super::ModalKind::LayerStateManager); self.command_line .push_output(crate::tf!("LAYERSTATE: updated \"{name}\".").as_ref()); Task::none() } Message::LayerStateEditorCancel => { self.layer_state_edit_draft = None; self.layer_state_edit_filter.clear(); self.layer_state_edit_color_open = None; self.active_modal = Some(super::ModalKind::LayerStateManager); Task::none() } Message::WindowCloseRequested(id) => { if self.main_window == Some(id) { return self.request_quit_with_block_edits(); } Task::none() } Message::OsWindowClosed(id) => { // Only the main window exists now; all dialogs are in-canvas // modals (Plan B). Closing it exits. if self.main_window == Some(id) { return self.exit_app(); } Task::none() } // ── Layer panel messages ─────────────────────────────────────── Message::LayerToggleVisible(name) => { let i = self.active_tab; let Some(idx) = self.layer_panel_index(i, &name) else { return Task::none(); }; // New state = toggle of the clicked row, applied to every target // (the whole selection when the clicked row is part of it) (#236). let on = self.tabs[i].layers.layers.get(idx).map(|l| !l.visible); let targets = self.layer_row_action_targets(i, idx); if let Some(on) = on { if !targets.is_empty() { let undo = self.begin_layer_undo(i, "LAYER OFF/ON", &targets); for name in &targets { if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { dl.flags.off = !on; } if let Some(pl) = self.tabs[i] .layers .layers .iter_mut() .find(|l| &l.name == name) { pl.visible = on; } } self.tabs[i].scene.invalidate_layer_visibility(&targets); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.command_line.push_output( crate::tf!( "{} layer(s) turned {}", targets.len(), if on { "on" } else { "off" } ) .as_ref(), ); self.sync_ribbon_layers(); } } Task::none() } Message::LayerSort(col) => { let i = self.active_tab; self.tabs[i].layers.sort_by(col); // Keep the ribbon dropdown's order (and its toggle indices) in // step with the re-sorted manager table. self.sync_ribbon_layers(); Task::none() } Message::LayerToggleLock(name) => { let i = self.active_tab; let Some(idx) = self.layer_panel_index(i, &name) else { return Task::none(); }; let locked = self.tabs[i].layers.layers.get(idx).map(|l| !l.locked); let targets = self.layer_row_action_targets(i, idx); if let Some(locked) = locked { if !targets.is_empty() { let undo = self.begin_layer_undo(i, "LAYER LOCK/UNLOCK", &targets); for name in &targets { if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { dl.flags.locked = locked; } if let Some(pl) = self.tabs[i] .layers .layers .iter_mut() .find(|l| &l.name == name) { pl.locked = locked; } } // Lock state affects editability, not rendered geometry. self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.command_line.push_output( crate::tf!( "{} layer(s) {}", targets.len(), if locked { "locked" } else { "unlocked" } ) .as_ref(), ); self.sync_ribbon_layers(); self.refresh_properties(); } } Task::none() } Message::LayerToggleFreeze(name) => { let i = self.active_tab; let Some(idx) = self.layer_panel_index(i, &name) else { return Task::none(); }; let frozen = self.tabs[i].layers.layers.get(idx).map(|l| !l.frozen); let targets = self.layer_row_action_targets(i, idx); if let Some(frozen) = frozen { if !targets.is_empty() { let undo = self.begin_layer_undo(i, "LAYER FREEZE", &targets); for name in &targets { if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { if frozen { dl.freeze(); } else { dl.thaw(); } } if let Some(pl) = self.tabs[i] .layers .layers .iter_mut() .find(|l| &l.name == name) { pl.frozen = frozen; } } self.tabs[i].scene.invalidate_layer_visibility(&targets); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.command_line.push_output( crate::tf!( "{} layer(s) {}", targets.len(), if frozen { "frozen" } else { "thawed" } ) .as_ref(), ); self.sync_ribbon_layers(); } } Task::none() } Message::LayerTogglePlot(name) => { let i = self.active_tab; let Some(idx) = self.layer_panel_index(i, &name) else { return Task::none(); }; let plottable = self.tabs[i] .layers .layers .get(idx) .map(|layer| !layer.plottable); let targets = self.layer_row_action_targets(i, idx); if let Some(plottable) = plottable { if !targets.is_empty() { let undo = self.begin_layer_undo(i, "LAYER PLOT/NOPLOT", &targets); for name in &targets { if let Some(layer) = self.tabs[i].scene.document.layers.get_mut(name) { layer.is_plottable = plottable; } if let Some(layer) = self.tabs[i] .layers .layers .iter_mut() .find(|layer| &layer.name == name) { layer.plottable = plottable; } } self.tabs[i].layers.refresh_sort(); self.tabs[i].scene.invalidate_layer_dependencies(&targets); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.command_line.push_output( crate::tf!( "{} layer(s) set to {}", targets.len(), if plottable { "Plot" } else { "No Plot" } ) .as_ref(), ); } } Task::none() } Message::LayerToggleVpFreeze(name, vp_col_idx) => { let Some(idx) = self.layer_panel_index(self.active_tab, &name) else { return Task::none(); }; self.on_layer_toggle_vp_freeze(idx, vp_col_idx) } Message::LayerNew => self.on_layer_new(), Message::LayerDelete => self.on_layer_delete(), Message::LayerDeleteConfirm => self.on_layer_delete_confirm(), Message::LayerSetCurrent => self.on_layer_set_current(), Message::LayerSelect(name) => { let i = self.active_tab; let Some(idx) = self.layer_panel_index(i, &name) else { return Task::none(); }; if self.tabs[i].layers.editing.is_some() { return Task::done(Message::LayerRenameCommit); } let (shift, ctrl) = (self.shift_down, self.ctrl_down); let panel = &mut self.tabs[i].layers; if ctrl { // Ctrl/Cmd-click toggles this row in the selection. if let Some(pos) = panel.selected_multi.iter().position(|&x| x == idx) { panel.selected_multi.remove(pos); } else { panel.selected_multi.push(idx); } } else if shift { // 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)); let visible: std::collections::HashSet = 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 // combo (linetype / lineweight) on one of the selected rows // keeps the selection and the edit stays bulk (#236). panel.selected_multi = vec![idx]; } panel.selected = Some(idx); Task::none() } Message::LayerRenameStart(name) => { let i = self.active_tab; let Some(idx) = self.layer_panel_index(i, &name) else { return Task::none(); }; self.tabs[i].layers.selected = Some(idx); self.tabs[i].layers.selected_multi = vec![idx]; self.tabs[i].layers.edit_buf = name.clone(); self.tabs[i].layers.editing = Some(name); Task::none() } Message::LayerRenameEdit(s) => { let i = self.active_tab; self.tabs[i].layers.edit_buf = s; Task::none() } Message::LayerRenameCommit => self.on_layer_rename_commit(), // A click moved focus off the name field: keep what was typed, as // Enter would. An untouched name stays open (the field may not have // taken focus yet on the click that opened it). Message::LayerRenameFocus(focused) => { let layers = &self.tabs[self.active_tab].layers; let typed = layers .editing .as_ref() .is_some_and(|name| *name != layers.edit_buf); if typed && focused != Some(crate::ui::window::layers::rename_input_id()) { return self.on_layer_rename_commit(); } Task::none() } Message::LayerColorPickerToggle(name) => { let i = self.active_tab; let Some(idx) = self.layer_panel_index(i, &name) else { return Task::none(); }; let panel = &mut self.tabs[i].layers; 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(name); panel.color_full_palette = false; panel.selected = Some(idx); // Opening the swatch on a row outside the current // multi-selection narrows to just that row; on a selected // row it keeps the multi-selection so the pick applies to all. if !panel.selected_multi.contains(&idx) { panel.selected_multi = vec![idx]; } } Task::none() } Message::LayerColorMorePalette => { let i = self.active_tab; self.tabs[i].layers.color_full_palette = !self.tabs[i].layers.color_full_palette; Task::none() } Message::LayerColorSet(color) => self.set_layer_color(color, None), Message::LayerLinetypeSet(lt) => { let i = self.active_tab; let names = self.selected_layer_names(i); if !names.is_empty() { let undo = self.begin_layer_undo(i, "LAYER LINETYPE", &names); for name in &names { if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { dl.line_type = lt.clone(); } } for pl in self.tabs[i].layers.layers.iter_mut() { if names.contains(&pl.name) { pl.linetype = lt.clone(); } } self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); // Linetype is baked into the cached wires; repaint. self.tabs[i].scene.invalidate_layer_dependencies(&names); } Task::none() } Message::LayerLineweightSet(lw) => { let i = self.active_tab; let names = self.selected_layer_names(i); if !names.is_empty() { let undo = self.begin_layer_undo(i, "LAYER LINEWEIGHT", &names); for name in &names { if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { dl.line_weight = lw; } } for pl in self.tabs[i].layers.layers.iter_mut() { if names.contains(&pl.name) { pl.lineweight = lw; } } self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); // Lineweight is baked into the cached wires; repaint. self.tabs[i].scene.invalidate_layer_dependencies(&names); } Task::none() } Message::LayerTransparencyEdit(name, s) => { let i = self.active_tab; let Some(idx) = self.layer_panel_index(i, &name) else { return Task::none(); }; let val = if let Ok(v) = s.parse::() { Some(v.clamp(0, 90)) } else if s.is_empty() { Some(0) } else { None }; // Apply the edited transparency to every selected layer (#236). if let Some(v) = val { let targets = self.layer_row_action_targets(i, idx); 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() } // ── Cursor / viewport messages ───────────────────────────────── Message::CursorMoved(p, viewport) => self.on_cursor_moved(p, viewport), // A multi-finger gesture owns the pointer while it runs. Message::ViewportMove(_) | Message::ViewportLeftPressAt(_) | Message::ViewportLeftPress | Message::ViewportLeftRelease | Message::PaneMove(..) | Message::PanePress(_) | Message::PaneRelease(_) if self.touch.active() => { Task::none() } Message::ViewportMove(p) => self.on_viewport_move(p), Message::ViewportExit => self.on_viewport_exit(), // ── Per-pane Model viewport ─────────────────────────────────── Message::PaneResized(ev) => self.on_pane_resized(ev), Message::PaneClicked(pane) => self.on_pane_clicked(pane), Message::PaneDragged(ev) => self.on_pane_dragged(ev), Message::PaneMove(idx, local) => { if self.color_picker.is_some() { return Task::none(); } let p = self.pane_canvas_point(idx, local); // While dragging a pane, just track the cursor (no focus swap or // snap) so the drop target reads cleanly. if self.pane_move_from.is_some() { self.tabs[self.active_tab] .scene .selection .borrow_mut() .input .last_move_pos = Some(p); return Task::none(); } self.focus_model_pane(idx); self.on_viewport_move(p) } Message::PaneMoveStart => { let i = self.active_tab; self.pane_move_from = Some(self.tabs[i].scene.active_model_tile.get()); Task::none() } Message::PanePress(idx) => { // A click-away into the drawing area re-syncs the active-row // highlight against real focus before the pick runs. let sweep = self.sync_active_field_if_any(); // A fresh press ends any stale (un-dropped) pane move. self.pane_move_from = None; self.focus_model_pane(idx); Task::batch(vec![sweep, self.on_viewport_left_press()]) } Message::PaneRelease(idx) => { // Finishing a pane-move drag: swap the source pane with the one // released over, instead of the normal release handling. if let Some(from) = self.pane_move_from.take() { let i = self.active_tab; self.tabs[i].scene.swap_model_panes(from, idx); self.tabs[i].scene.camera_generation += 1; return Task::none(); } self.focus_model_pane(idx); self.on_viewport_left_release() } Message::PaneRightPress(idx) => { self.focus_model_pane(idx); self.update(Message::ViewportRightPress) } Message::PaneRightRelease(idx) => { self.focus_model_pane(idx); self.update(Message::ViewportRightRelease) } Message::PaneMiddlePress(idx) => { self.focus_model_pane(idx); self.update(Message::ViewportMiddlePress) } Message::PaneMiddleRelease(idx) => { self.focus_model_pane(idx); self.update(Message::ViewportMiddleRelease) } Message::PaneScroll(idx, d) => { self.focus_model_pane(idx); self.update(Message::ViewportScroll(d)) } Message::ViewportLeftPressAt(position) => { // Use the pointer location captured by the actual button // event. The last hover can lag behind fast cursor motion, // particularly while expensive OSNAP/OTRACK previews run. // Refresh the snap and world point before accepting the pick. let moved = self.on_viewport_move(position); self.graphic_attributes.open_menu = None; let sweep = self.sync_active_field_if_any(); Task::batch(vec![moved, sweep, self.on_viewport_left_press()]) } Message::ViewportLeftPress => { self.graphic_attributes.open_menu = None; let sweep = self.sync_active_field_if_any(); Task::batch(vec![sweep, self.on_viewport_left_press()]) } Message::ViewportLeftRelease => self.on_viewport_left_release(), Message::ViewportRightPress => { self.graphic_attributes.open_menu = None; let i = self.active_tab; self.tabs[i].measurement.dismiss_result(); self.ribbon.close_dropdown(); // Shift+RMB: the one-shot snap override menu at the cursor — // pick a snap for just the next point, then it expires (#337). if self.shift_down { let pos = self.tabs[i].scene.selection.borrow().input.last_move_pos; if let Some(p) = pos { self.snap_override_popup = Some(p); } return Task::none(); } let mut sel = self.tabs[i].scene.selection.borrow_mut(); let Some(p) = sel.input.last_move_pos else { return Task::none(); }; sel.menu.open_at = None; sel.input.right_down = true; sel.input.right_press_pos = Some(p); sel.input.right_press_time = Some(iced::time::Instant::now()); sel.input.right_last_pos = Some(p); sel.input.right_dragging = false; Task::none() } Message::ViewportRightRelease => { let i = self.active_tab; let mut sel = self.tabs[i].scene.selection.borrow_mut(); let Some(click_pos) = sel.input.last_move_pos else { return Task::none(); }; if !sel.input.right_down { return Task::none(); } 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 .map_or(0, |t| t.elapsed().as_millis() as i32); sel.input.right_down = false; sel.input.right_press_pos = None; sel.input.right_press_time = None; sel.input.right_last_pos = None; sel.input.right_dragging = false; sel.orbit.pivot = None; if !was_click { return Task::none(); } // A command name (or option keyword / value) typed into the // command line but not yet entered runs on right-click, exactly // as pressing Enter would. Route through CommandFinalize — the // canonical Enter action — so the same MText / grip-popup guards // apply and a non-empty line is forwarded to the submit path. // Without this the typed text would be swallowed by the context // menu (when idle) or the Enter cycle. Every other right-click // behaviour below is unchanged and only applies when the command // line is empty. Pending text always runs and resets the Enter // cycle so the next right-click acts as Enter again. if !self.command_line.input.trim().is_empty() { sel.input.right_click_entered = false; drop(sel); return self.update(Message::CommandFinalize); } // A right-click. What it does is the user's choice (Options → // User Preferences, SHORTCUTMENU in commercial solutions): // • Shortcut menu — always open the context menu, whose // default row (Enter / Repeat) sits under the pointer. // • Time-sensitive — a quick click is Enter while a command // runs (repeat the last command when idle); a held click // opens the menu. // • Enter first — while a command is active the first // right-click acts as Enter and a second consecutive one // opens the menu; idle always opens the menu. Any other // interaction — a left-click pick or a new command — resets // that cycle so the next right-click is Enter again. let has_cmd = self.tabs[i].active_cmd.is_some(); let open_menu = match self.right_click_mode { super::settings::RightClickMode::ShortcutMenu => true, super::settings::RightClickMode::TimeSensitive => { held_ms >= self.right_click_hold_ms } super::settings::RightClickMode::EnterFirst => { !(has_cmd && !sel.input.right_click_entered) } }; if !open_menu { sel.input.right_click_entered = true; drop(sel); // CommandFinalize is Enter during a command and "repeat // the last command" when idle — exactly the quick // right-click. return self.update(Message::CommandFinalize); } sel.input.right_click_entered = false; sel.menu.open(click_pos); drop(sel); // Take the keyboard away from the command-line field so keys // reach the menu through the global subscription; the field // is re-focused when the menu closes. self.unfocus_widgets() } Message::ViewportMiddlePress => { self.graphic_attributes.open_menu = None; self.on_viewport_middle_press() } Message::ViewportMiddleRelease => { let i = self.active_tab; let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.input.middle_down = false; sel.input.middle_last_pos = None; // End of a Shift+MMB orbit — drop the captured pivot so the next // gesture recomputes it against the current selection. (#229) sel.orbit.pivot = None; drop(sel); self.arm_hover_after_navigation(i); Task::none() } Message::ViewportScroll(delta) => self.on_viewport_scroll(delta), Message::ViewportClick(viewport) => self.on_viewport_click(viewport), Message::WindowResized(w, h) => { self.vp_size = ((w - 440.0).max(200.0), h); self.win_size = (w, h); Task::none() } Message::ViewCubeSnap(region) => self.on_view_cube_snap(region), Message::ViewCubeSnapWorld(region) => self.on_view_cube_snap_world(region), Message::ViewCubeHome => { let i = self.active_tab; self.clear_navigation_hover(i); self.tabs[i].scene.remember_current_view(); let r_ucs = self.tabs[i].scene.viewcube_ucs_mat(); if self.tabs[i].scene.active_viewport.is_some() { self.tabs[i] .scene .mutate_active_viewport_camera(|c| c.home_view(r_ucs)); } else { self.tabs[i].scene.camera.borrow_mut().home_view(r_ucs); } self.tabs[i].scene.camera_generation += 1; self.command_line .push_output(crate::t!("View: Home").as_ref()); Task::none() } Message::ViewCubeRoll(cw) => { let i = self.active_tab; self.clear_navigation_hover(i); self.tabs[i].scene.remember_current_view(); let ang = if cw { std::f32::consts::FRAC_PI_2 } else { -std::f32::consts::FRAC_PI_2 }; if self.tabs[i].scene.active_viewport.is_some() { self.tabs[i] .scene .mutate_active_viewport_camera(|c| c.roll_by(ang)); } else { self.tabs[i].scene.camera.borrow_mut().roll_by(ang); } self.tabs[i].scene.camera_generation += 1; Task::none() } Message::ViewCubeNudge(dir) => { use crate::scene::NudgeDir; let (horizontal, positive) = match dir { NudgeDir::Up => (false, false), NudgeDir::Down => (false, true), NudgeDir::Left => (true, false), NudgeDir::Right => (true, true), }; let i = self.active_tab; self.clear_navigation_hover(i); self.tabs[i].scene.remember_current_view(); if self.tabs[i].scene.active_viewport.is_some() { self.tabs[i] .scene .mutate_active_viewport_camera(|c| c.nudge_90(horizontal, positive)); } else { self.tabs[i] .scene .camera .borrow_mut() .nudge_90(horizontal, positive); } self.tabs[i].scene.camera_generation += 1; Task::none() } Message::SetViewcubeUcs(name) => { let i = self.active_tab; let mut changed = false; if name.is_empty() || name == "WCS" { self.tabs[i].active_ucs = None; self.command_line .push_output(crate::t!("UCS: World").as_ref()); changed = true; } else if let Some(named) = self.tabs[i].scene.document.ucss.get(&name).cloned() { self.tabs[i].active_ucs = Some(named); self.command_line .push_output(crate::tf!("UCS: {}", name).as_ref()); changed = true; } if changed { self.commit_active_ucs_change(i, "UCS"); self.tabs[i].scene.camera_generation += 1; } Task::none() } Message::LayoutSettled => { // Restore the scene after the notice redraw. self.layout_settling = false; Task::none() } Message::GripDwellTick => { let i = self.active_tab; // Reuse the move-time logic — `p` is the last cursor // position the viewport saw, which is also what the // hover state was last set with. let p = self.tabs[i] .scene .selection .borrow() .input .last_move_pos .unwrap_or(self.cursor_pos); self.update_grip_hover(i, p); Task::none() } Message::HoverDwellTick => self.on_hover_dwell_tick(), Message::InteractionIndexReady { tab_id, epoch, source, wires, index, build_ms, } => { if self.active_interaction_index == Some((tab_id, epoch, source)) { self.active_interaction_index = None; } let installed = self .tabs .iter() .position(|tab| tab.id == tab_id) .is_some_and(|i| { self.tabs[i] .scene .install_prepared_interaction_index(epoch, source, wires, index) }); if crate::perf::enabled() { crate::perf_record!( "[perf] interaction-index-bg {:>7.1}ms installed={installed}", build_ms, ); } while let Some(( queued_tab, queued_epoch, queued_source, queued_wires, screen_height, )) = self.queued_interaction_indices.pop_front() { let Some(i) = self.tabs.iter().position(|tab| tab.id == queued_tab) else { continue; }; let stale = self.tabs[i].scene.geometry_epoch != queued_epoch || std::sync::Arc::as_ptr(&queued_wires) as usize != queued_source; let (wires, screen_height) = if stale { ( self.tabs[i].scene.hit_test_wires(), self.tabs[i].scene.selection.borrow().view.vp_size.1, ) } else { (queued_wires, screen_height) }; if let Some(task) = self.prepare_interaction_index_task(i, wires, screen_height) { return task; } } if self.active_interaction_index.is_none() && !self.tabs.is_empty() { let i = self.active_tab.min(self.tabs.len() - 1); let wires = self.tabs[i].scene.hit_test_wires(); let screen_height = self.tabs[i].scene.selection.borrow().view.vp_size.1; self.prepare_interaction_index_task(i, wires, screen_height) .unwrap_or_else(Task::none) } else { Task::none() } } Message::VisibilityPick(idx) => { if let Some(popup) = self.visibility_popup.take() { self.apply_visibility_state(popup.insert_handle, idx); } Task::none() } Message::GripMenuPick(idx) => self.on_grip_menu_pick(idx), // ── Snap / mode toggles ─────────────────────────────────────── Message::ToggleSnapEnabled => { self.snapper.toggle_global(); self.sync_vport_display(self.active_tab); self.persist_settings_if_changed(); Task::none() } Message::ToggleSnap3dEnabled => { self.snapper.toggle_snap3d(); self.sync_vport_display(self.active_tab); Task::none() } Message::ToggleGridSnap => { self.snapper.toggle_grid_snap(); self.sync_vport_display(self.active_tab); Task::none() } Message::ToggleIsometricDrafting => { self.isometric_drafting = !self.isometric_drafting; self.persist_settings_if_changed(); Task::none() } Message::SetIsoPlane(plane) => { self.isometric_drafting = true; self.iso_plane = plane; self.persist_settings_if_changed(); Task::none() } Message::CycleIsoPlane => { if self.isometric_drafting { self.iso_plane = self.iso_plane.next(); } else { self.isometric_drafting = true; } self.command_line.push_output( crate::tf!("Isometric plane: {}.", self.iso_plane.label()).as_ref(), ); self.persist_settings_if_changed(); Task::none() } Message::ResetDraftingRotation => { self.snap_angle_deg = 0.0; let i = self.active_tab; if self.tabs[i].active_ucs.is_some() { self.tabs[i].active_ucs = None; self.commit_active_ucs_change(i, "UCS"); self.tabs[i].scene.camera_generation += 1; } self.command_line .push_output(crate::t!("Drafting rotation reset to World at 0°.").as_ref()); self.persist_settings_if_changed(); Task::none() } Message::ToggleGrid => { self.show_grid ^= true; self.sync_vport_display(self.active_tab); Task::none() } Message::ToggleOrtho => { self.ortho_mode ^= true; if self.ortho_mode { self.polar_mode = false; } // If the user manually toggles ortho during a command that // suppressed it (e.g. RECTANG), the toggle is permanent — // don't restore the pre-command state when the command ends. self.rect_suppressed_ortho = false; Task::none() } Message::ToggleLineweightDisplay => { let i = self.active_tab; if i < self.tabs.len() { let h = &mut self.tabs[i].scene.document.header; h.lineweight_display = !h.lineweight_display; // No retessellate — the wire shader reads the flag from uniforms. self.tabs[i].dirty = true; } Task::none() } Message::CycleCoordsMode => { // $COORDS 0 (static) → 1 (live absolute) → 2 (polar) → 0. let i = self.active_tab; if i < self.tabs.len() { let mode = { let h = &mut self.tabs[i].scene.document.header; h.coords_mode = (h.coords_mode + 1).rem_euclid(3); h.coords_mode }; self.tabs[i].dirty = true; let label = match mode { 0 => "static", 2 => "polar", _ => "live", }; self.command_line .push_output(crate::tf!("COORDS = {mode} ({label})").as_ref()); } Task::none() } Message::ResolveOneParametricConflict => { self.resolve_one_parametric_conflict(); Task::none() } Message::TogglePolar => { self.polar_mode ^= true; if self.polar_mode { self.ortho_mode = false; } Task::none() } Message::ToggleDynInput => { self.set_dyn_input(!self.dyn_input); Task::none() } Message::ToggleViewCube => { self.show_viewcube ^= true; self.ribbon.set_viewcube(self.show_viewcube); Task::none() } Message::ToggleProperties => { let id = crate::ui::dock::PanelId::Properties; let open = !self.dock_panel_visible(id); if self.clean_screen { // Shown over clean screen without changing the panel's // normal state. self.clean_screen_properties = open; if !open { return Task::none(); } } self.dock_set_open(id, open) } Message::ToggleGraphicAttributes => { let id = crate::ui::dock::PanelId::GraphicAttributes; let open = !self.show_graphic_attributes; if open { self.dock.dock_below_properties(id); } self.dock_set_open(id, open) } Message::ToggleFileTabs => { self.show_file_tabs ^= true; self.ribbon.set_file_tabs(self.show_file_tabs); Task::none() } Message::ToggleLayoutTabs => { self.show_layout_tabs ^= true; self.ribbon.set_layout_tabs(self.show_layout_tabs); Task::none() } Message::ToggleOTrack => { self.snapper.otrack_enabled ^= true; if !self.snapper.otrack_enabled { self.snapper.clear_tracking(); self.otrack_active = None; self.otrack_cross = None; self.otrack_kind = None; } Task::none() } Message::SetPolarAngle(deg) => { self.polar_increment_deg = deg; self.polar_mode = true; self.ortho_mode = false; self.polar_popup_open = false; Task::none() } Message::TogglePolarPopup => { // MenuBar owns its open state. Reset only the transient field // whenever the caret starts a fresh interaction. self.polar_custom_input.clear(); Task::none() } Message::ClosePolarPopup => { self.polar_popup_open = false; Task::none() } Message::PolarCustomInput(s) => { self.polar_custom_input = s; Task::none() } Message::SubmitPolarCustom => { // Accept any positive angle up to a full turn; ignore garbage. if let Ok(v) = self.polar_custom_input.trim().parse::() { if v > 0.0 && v <= 360.0 { self.polar_increment_deg = v; self.polar_mode = true; self.ortho_mode = false; } } self.polar_custom_input.clear(); self.polar_popup_open = false; Task::none() } Message::SetAnnotationScale(scale) => { self.scale_popup_open = false; let auto_scale = self.annotation_auto_scale; if let Some(tab) = self.tabs.get_mut(self.active_tab) { let previous = tab.scene.displayed_annotation_scale_handle(); if let Some(handle) = tab.scene.set_annotation_scale_named(&scale) { if auto_scale > 0 { tab.scene.add_annotation_scale_to_objects( handle, previous, auto_scale as u8, ); } tab.dirty = true; } } Task::none() } Message::SetViewportScale(scale) => { self.scale_popup_open = false; let auto_scale = self.annotation_auto_scale; if let Some(tab) = self.tabs.get_mut(self.active_tab) { let previous = tab.scene.displayed_annotation_scale_handle(); if let Some(handle) = tab.scene.set_viewport_scale_named(&scale) { if auto_scale > 0 { tab.scene.add_annotation_scale_to_objects( handle, previous, auto_scale as u8, ); } tab.dirty = true; } } Task::none() } Message::ToggleAnnotationVisibility => { if let Some(tab) = self.tabs.get_mut(self.active_tab) { let value = !tab.scene.annotation_all_visible(); tab.scene.set_annotation_all_visible(value); tab.dirty = true; } Task::none() } Message::ToggleAnnotationAutoAdd => { self.annotation_auto_scale = match self.annotation_auto_scale { 0 => 4, value => -value, }; Task::none() } Message::SyncViewportAnnotationScale => { if let Some(tab) = self.tabs.get_mut(self.active_tab) { if tab.scene.sync_viewport_annotation_scale() { tab.dirty = true; } } Task::none() } Message::ToggleScalePopup => { self.scale_popup_open ^= true; Task::none() } Message::CloseScalePopup => { self.scale_popup_open = false; Task::none() } Message::ScaleManagerOpen => { let i = self.active_tab; self.scale_popup_open = false; // Snapshot so New / Copy / Delete / edits revert if closed // without Apply. self.scale_stage_begin(); // Fallback scales are virtual (no real objects), so they can't // be edited or renamed. Materialise the standard set into real // staged objects so the manager behaves like a drawing with its // own list; the stage reverts them on close unless applied. if self.tabs[i].scene.ensure_real_scale_list() { self.scale_stage_materialized(); } self.scale_rename = None; let cur = self.tabs[i] .scene .document .header .current_annotation_scale .clone(); // Select the current scale, or the first one if it isn't listed. if self.tabs[i].scene.scale_paper_drawing(&cur).is_some() { self.load_scale_editor(&cur); } else if let Some((first, _, _)) = self.tabs[i].scene.scale_list().into_iter().next() { self.load_scale_editor(&first); } self.active_modal = Some(crate::app::ModalKind::ScaleManager); Task::none() } Message::AnnoObjectScaleOpen => { // The dialog edits a single object's per-scale memberships. let i = self.active_tab; let handles = self.property_target_handles(i); if handles.len() == 1 { let ok = self.tabs[i] .scene .document .get_entity(handles[0]) .is_some_and(crate::scene::annotative::supports_annotation_context); if ok { self.anno_object_scale_target = Some(handles[0]); self.active_modal = Some(crate::app::ModalKind::AnnoObjectScale); } else { self.command_line.push_info( crate::t!("The selected object does not support annotation scales.") .as_ref(), ); } } else { self.command_line.push_info( crate::t!("Select one object first, then run OBJECTSCALE.").as_ref(), ); } Task::none() } Message::PropHyperlinkOpen => { let i = self.active_tab; let handles = self.property_target_handles(i); if handles.is_empty() { return Task::none(); } let mut first: Option<(String, String)> = None; let mut mixed = false; for handle in &handles { let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) else { continue; }; let current = ( crate::scene::pe_url_of(entity) .unwrap_or_default() .to_owned(), crate::scene::pe_url_description_of(entity) .unwrap_or_default() .to_owned(), ); if first.as_ref().is_some_and(|value| value != ¤t) { mixed = true; } else if first.is_none() { first = Some(current); } } let (url, description) = if mixed { (String::new(), String::new()) } else { first.unwrap_or_default() }; self.hyperlink_editor_handles = handles; self.hyperlink_editor_url = url; self.hyperlink_editor_description = description; self.hyperlink_editor_mixed = mixed; self.hyperlink_editor_dirty = false; self.active_modal = Some(crate::app::ModalKind::Hyperlink); Task::none() } Message::HyperlinkUrlChanged(value) => { self.hyperlink_editor_url = value; self.hyperlink_editor_dirty = true; Task::none() } Message::HyperlinkDescriptionChanged(value) => { self.hyperlink_editor_description = value; self.hyperlink_editor_dirty = true; Task::none() } Message::HyperlinkApply => { if !self.hyperlink_editor_dirty { self.close_active_modal(); return Task::none(); } let url = self.hyperlink_editor_url.trim().to_owned(); if self.hyperlink_editor_mixed && url.is_empty() { self.command_line.push_info( crate::t!("Enter a URL, or use Remove to clear all hyperlinks.").as_ref(), ); return Task::none(); } let description = self.hyperlink_editor_description.trim().to_owned(); if let Some(block_def) = self.block_definition.as_mut() { block_def.hyperlink_url = url; block_def.hyperlink_desc = description; self.active_modal = Some(crate::app::ModalKind::BlockDefinition); return Task::none(); } let values = if url.is_empty() { None } else { let mut values = vec![codec::xdata::XDataValue::String(url)]; if !description.is_empty() { values.push(codec::xdata::XDataValue::String(description)); } Some(values) }; let i = self.active_tab; let handles = self.hyperlink_editor_handles.clone(); self.apply_property_op(i, "HYPERLINK", &handles, |app, handle| { crate::scene::view::dispatch::set_entity_xdata( &mut app.tabs[i].scene.document, handle, "PE_URL", values.clone(), ); }); self.close_active_modal(); Task::none() } Message::HyperlinkRemove => { if let Some(block_def) = self.block_definition.as_mut() { block_def.hyperlink_url.clear(); block_def.hyperlink_desc.clear(); self.active_modal = Some(crate::app::ModalKind::BlockDefinition); return Task::none(); } let i = self.active_tab; let handles = self.hyperlink_editor_handles.clone(); self.apply_property_op(i, "HYPERLINK", &handles, |app, handle| { crate::scene::view::dispatch::set_entity_xdata( &mut app.tabs[i].scene.document, handle, "PE_URL", None, ); }); self.close_active_modal(); Task::none() } Message::HyperlinkCancel => { if self.block_definition.is_some() { self.active_modal = Some(crate::app::ModalKind::BlockDefinition); return Task::none(); } self.close_active_modal(); Task::none() } Message::AnnoObjectScaleToggle(name) => { let i = self.active_tab; 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() } Message::ScaleManagerSelect(name) => { // Stage the current editor edits before switching so they aren't // lost, then load the newly-selected scale. self.scale_rename = None; self.scale_apply_current(); self.load_scale_editor(&name); Task::none() } Message::ScaleManagerPaperBuf(s) => { self.scale_manager_paper_buf = s; Task::none() } Message::ScaleManagerDrawingBuf(s) => { self.scale_manager_drawing_buf = s; Task::none() } Message::ScaleManagerNew => { // Add a new scale to the list immediately (staged) and select it, // like the style managers' New. The user edits its name / ratio; // it's kept only if Apply is pressed before the window closes. self.scale_apply_current(); let i = self.active_tab; let name = self.unique_scale_name("New Scale"); if self.tabs[i].scene.add_scale(&name, 1.0, 1.0) { self.load_scale_editor(&name); self.scale_stage_mark(); } Task::none() } Message::ScaleManagerCopy => { // Duplicate the selected scale under a unique name (staged). self.scale_apply_current(); let i = self.active_tab; let sel = self.scale_manager_selected.clone(); if !sel.is_empty() { let (paper, drawing) = self.tabs[i] .scene .scale_paper_drawing(&sel) .unwrap_or((1.0, 1.0)); let name = self.unique_scale_name(&sel); if self.tabs[i].scene.add_scale(&name, paper, drawing) { self.load_scale_editor(&name); self.scale_stage_mark(); } } Task::none() } Message::ScaleRenameStart(name) => { // Stage current editor edits, then rename this row inline. self.scale_apply_current(); self.scale_rename_buf = name.clone(); self.scale_rename = Some(name); iced::widget::operation::focus(crate::ui::style::scale_manager::rename_input_id()) } Message::ScaleRenameEdit(s) => { self.scale_rename_buf = s; Task::none() } Message::ScaleRenameCommit => { let i = self.active_tab; if let Some(old) = self.scale_rename.take() { let new = self.scale_rename_buf.trim().to_string(); if !new.is_empty() && !new.eq_ignore_ascii_case(&old) { let (paper, drawing) = self.tabs[i] .scene .scale_paper_drawing(&old) .unwrap_or((1.0, 1.0)); // Only fall back to add_scale for a built-in fallback (no // real object); never for a real scale whose rename was // rejected (name collision) — that would duplicate it. let ok = self.tabs[i].scene.edit_scale(&old, &new, paper, drawing) || (self.tabs[i].scene.scale_paper_drawing(&old).is_none() && self.tabs[i].scene.add_scale(&new, paper, drawing)); if ok { if self.tabs[i] .scene .document .header .current_annotation_scale .eq_ignore_ascii_case(&old) { self.tabs[i].scene.document.header.current_annotation_scale = new.clone(); } if self.scale_manager_selected.eq_ignore_ascii_case(&old) { self.load_scale_editor(&new); } self.scale_stage_mark(); } } } Task::none() } Message::ScaleManagerApply => { // Fold the editor into the selected scale, then commit the staged // transaction (this edit plus any New / Copy / Delete since open) // as one undo entry. self.scale_apply_current(); self.scale_stage_commit(); Task::none() } Message::ScaleManagerDelete => { // Staged: reverted on close unless a later Apply commits it. let i = self.active_tab; let sel = self.scale_manager_selected.clone(); let cur = self.tabs[i] .scene .document .header .current_annotation_scale .clone(); if !sel.is_empty() && !sel.eq_ignore_ascii_case(&cur) { if self.tabs[i].scene.remove_scale(&sel) { self.scale_manager_selected.clear(); self.scale_manager_paper_buf.clear(); self.scale_manager_drawing_buf.clear(); self.scale_stage_mark(); } } Task::none() } Message::ScaleManagerSetCurrent => { // Set Current takes effect immediately, exactly like the scale // pill — it isn't part of the staged list transaction, so it is // never rolled back when the manager closes. let i = self.active_tab; let sel = self.scale_manager_selected.clone(); let previous = self.tabs[i].scene.displayed_annotation_scale_handle(); if let Some(scale) = self.tabs[i].scene.set_annotation_scale_named(&sel) { if self.annotation_auto_scale > 0 { self.tabs[i].scene.add_annotation_scale_to_objects( scale, previous, self.annotation_auto_scale as u8, ); } self.tabs[i].dirty = true; } Task::none() } Message::ToggleLayoutList => { if self.tabs[self.active_tab].is_start { self.layout_list_open = false; return Task::none(); } self.layout_list_open ^= true; Task::none() } Message::CloseLayoutList => { self.layout_list_open = false; Task::none() } Message::ToggleStatusBarMenu => { self.statusbar_menu_open ^= true; Task::none() } Message::CloseStatusBarMenu => { self.statusbar_menu_open = false; Task::none() } Message::ToggleStatusPill(pill) => { // Keep the menu open so several pills can be toggled in a row. self.statusbar_config.toggle(pill); self.save_config(); Task::none() } Message::ToggleCleanScreen => { self.clean_screen ^= true; self.clean_screen_properties = false; Task::none() } Message::ToggleTransparencyDisplay => { let i = self.active_tab; if i < self.tabs.len() { // No retessellate — the wire shader reads the flag from uniforms. self.tabs[i].scene.transparency_display ^= true; } Task::none() } Message::ToggleQuickProperties => { self.quick_properties ^= true; if self.quick_properties { self.quick_properties_anchor = self.tabs[self.active_tab].last_cursor_screen; } self.save_config(); Task::none() } Message::ToggleSelectionCycling => { self.selection_cycling ^= true; self.cycle_candidates = None; self.tabs[self.active_tab].scene.set_hover_highlight(None); Task::none() } Message::CycleSelect(handle) => { // Add the picked object to the current selection (accumulate). let quick_properties_anchor = self.cycle_candidates.as_ref().map(|(point, _)| *point); self.cycle_candidates = None; let i = self.active_tab; self.tabs[i].scene.set_hover_highlight(None); self.tabs[i].scene.select_entity(handle, false); self.tabs[i].scene.expand_selection_for_groups(&[handle]); self.refresh_properties(); if let Some(point) = quick_properties_anchor { self.quick_properties_anchor = point; } Task::none() } Message::CycleHover(handle) => { let i = self.active_tab; self.tabs[i].scene.set_hover_highlight(handle); Task::none() } Message::CycleHoverExit(handle) => { // Only clear if another row hasn't already taken the highlight; // enter/exit can fire out of order when moving between rows. let i = self.active_tab; if self.tabs[i].scene.hover_highlight == Some(handle) { self.tabs[i].scene.set_hover_highlight(None); } Task::none() } Message::CycleCancel => { self.cycle_candidates = None; self.tabs[self.active_tab].scene.set_hover_highlight(None); Task::none() } Message::ToggleSelectionFilterPopup => { self.selection_filter_popup_open ^= true; Task::none() } Message::CloseSelectionFilterPopup => { self.selection_filter_popup_open = false; Task::none() } Message::ToggleSelectionFilterType(name) => { let f = &mut self.tabs[self.active_tab].scene.selection_filter; if !f.remove(&name) { f.insert(name); } Task::none() } Message::SelectionFilterSelectAll => { self.tabs[self.active_tab].scene.selection_filter.clear(); Task::none() } Message::SelectionFilterClearAll => { let i = self.active_tab; let types = self.tabs[i].scene.entity_type_names_in_layout(); let f = &mut self.tabs[i].scene.selection_filter; for t in types.iter() { f.insert(t.clone()); } Task::none() } Message::ToggleUnitsPopup => { self.units_popup_open ^= true; Task::none() } Message::CloseUnitsPopup => { self.units_popup_open = false; Task::none() } Message::OpenDrawingUnits => { self.units_popup_open = false; let header = &self.tabs[self.active_tab].scene.document.header; self.drawing_units = Some(crate::ui::window::drawing_units::State { linear_format: header.linear_unit_format, linear_precision: header.linear_unit_precision, angular_format: header.angular_unit_format, angular_precision: header.angular_unit_precision, clockwise: header.angle_direction != 0, base_angle: format!("{:.6}", header.angle_base.to_degrees()) .trim_end_matches('0') .trim_end_matches('.') .to_string(), insertion_units: header.insertion_units, }); self.active_modal = Some(crate::app::ModalKind::DrawingUnits); Task::none() } Message::DrawingUnitsField(field) => { use crate::ui::window::drawing_units::Field; let Some(state) = self.drawing_units.as_mut() else { return Task::none(); }; match field { Field::LinearFormat(v) => state.linear_format = v, Field::LinearPrecision(v) => state.linear_precision = v, Field::AngularFormat(v) => state.angular_format = v, Field::AngularPrecision(v) => state.angular_precision = v, Field::Clockwise(v) => state.clockwise = v, // Kept as typed so a lone "-" or a trailing "." survives // until the number it is becoming is finished. Field::BaseAngle(v) => state.base_angle = v, Field::InsertionUnits(v) => state.insertion_units = v, } Task::none() } Message::DrawingUnitsApply => { let Some(state) = self.drawing_units.take() else { self.active_modal = None; return Task::none(); }; self.active_modal = None; let i = self.active_tab; self.push_undo_snapshot(i, "UNITS"); let header = &mut self.tabs[i].scene.document.header; header.linear_unit_format = state.linear_format; header.linear_unit_precision = state.linear_precision; header.angular_unit_format = state.angular_format; header.angular_unit_precision = state.angular_precision; header.angle_direction = i16::from(state.clockwise); // A base angle that will not parse is a half-finished edit, not // an instruction to move zero — leave the drawing's own value. if let Ok(degrees) = state.base_angle.trim().parse::() { header.angle_base = degrees.to_radians(); } header.insertion_units = state.insertion_units; self.tabs[i].scene.sync_annotation_scale_from_header(); self.tabs[i].scene.invalidate_annotation_dependencies(); self.tabs[i].dirty = true; 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, ), ); Task::none() } Message::BlockDefName(name) => { if let Some(state) = self.block_definition.as_mut() { state.name = name; state.error_message = None; state.confirm_redefine = None; } Task::none() } Message::BlockDefNameSelect(chosen) => { if let Some(state) = self.block_definition.as_mut() { state.name = chosen.clone(); state.error_message = None; state.confirm_redefine = None; // Redefining an existing block starts from the base point // that definition already has (0.0000 for a new block). let base = crate::scene::render_graph::block_base_point( &self.tabs[self.active_tab].scene.document, &chosen, ); state.set_base_point(glam::DVec3::new(base.x, base.y, base.z)); } Task::none() } Message::BlockDefBaseOnScreen(val) => { if let Some(state) = self.block_definition.as_mut() { state.base_point_specify_onscreen = val; } Task::none() } Message::BlockDefPickPoint => { self.active_modal = None; let cmd = crate::modules::insert::create_block::BlockPickBasePointCommand; self.command_line.push_info(&cmd.prompt()); self.tabs[self.active_tab].active_cmd = Some(Box::new(cmd)); Task::none() } Message::BlockDefBaseX(val) => { if let Some(state) = self.block_definition.as_mut() { state.base_point_x = val; } Task::none() } Message::BlockDefBaseY(val) => { if let Some(state) = self.block_definition.as_mut() { state.base_point_y = val; } Task::none() } Message::BlockDefBaseZ(val) => { if let Some(state) = self.block_definition.as_mut() { state.base_point_z = val; } Task::none() } Message::BlockDefObjectsOnScreen(val) => { if let Some(state) = self.block_definition.as_mut() { state.objects_specify_onscreen = val; } Task::none() } Message::BlockDefSelectObjects => { self.active_modal = None; use crate::modules::draw::select::SelectObjectsCommand; let cmd = SelectObjectsCommand::plain("BLOCK", "BLOCK_OBJECTS_GATHERED"); self.command_line.push_info(&cmd.prompt()); self.tabs[self.active_tab].active_cmd = Some(Box::new(cmd)); Task::none() } Message::BlockDefQuickSelect => { self.active_modal = None; self.on_qselect_open() } Message::BlockDefObjectMode(mode) => { if let Some(state) = self.block_definition.as_mut() { state.object_mode = mode; } Task::none() } Message::BlockDefAnnotative(val) => { if let Some(state) = self.block_definition.as_mut() { state.annotative = val; if !val { state.match_orientation = false; } } Task::none() } Message::BlockDefMatchOrientation(val) => { if let Some(state) = self.block_definition.as_mut() { state.match_orientation = val; } Task::none() } Message::BlockDefScaleUniformly(val) => { if let Some(state) = self.block_definition.as_mut() { state.scale_uniformly = val; } Task::none() } Message::BlockDefAllowExploding(val) => { if let Some(state) = self.block_definition.as_mut() { state.allow_exploding = val; } Task::none() } Message::BlockDefUnit(unit) => { if let Some(state) = self.block_definition.as_mut() { state.unit = unit; } Task::none() } Message::BlockDefDescription(desc) => { if let Some(state) = self.block_definition.as_mut() { state.description = desc; } Task::none() } Message::BlockDefDescriptionAction(action) => { if let Some(state) = self.block_definition.as_mut() { state.description_content.perform(action); state.description = state.description_content.text(); } Task::none() } Message::BlockDefHyperlink => { if let Some(state) = self.block_definition.as_ref() { self.hyperlink_editor_url = state.hyperlink_url.clone(); self.hyperlink_editor_description = state.hyperlink_desc.clone(); self.hyperlink_editor_mixed = false; self.hyperlink_editor_dirty = false; self.hyperlink_editor_handles.clear(); self.active_modal = Some(crate::app::ModalKind::Hyperlink); } Task::none() } Message::BlockDefDismissError => { if let Some(state) = self.block_definition.as_mut() { state.error_message = None; } Task::none() } Message::BlockDefHelp => { self.command_line.push_info( crate::t!("BLOCK creates a block definition from objects you select.").as_ref(), ); Task::none() } Message::BlockDefConfirmRedefine(confirmed) => { if !confirmed { if let Some(state) = self.block_definition.as_mut() { state.confirm_redefine = None; } return Task::none(); } self.commit_block_definition(true) } Message::BlockDefApply => { let Some(state) = self.block_definition.as_mut() else { return Task::none(); }; let name = state.name.trim().to_string(); if name.is_empty() { state.error_message = Some( crate::t!("The name must be at least one character long.").into_owned(), ); return Task::none(); } if let Some(_ch) = state.invalid_name_char() { state.error_message = Some( crate::t!("The name contains unsupported characters. The following characters are not supported: \\ < > / ? \" : ; * | , = `") .into_owned(), ); return Task::none(); } if name.starts_with('*') { state.error_message = Some(crate::t!("Block name cannot start with '*'.").into_owned()); return Task::none(); } 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(), ); return Task::none(); } let i = self.active_tab; if self.tabs[i] .scene .document .block_records .get(&name) .is_some() { state.confirm_redefine = Some(name); return Task::none(); } self.commit_block_definition(false) } Message::WblockSourceMode(mode) => { if let Some(state) = self.wblock.as_mut() { state.source_mode = mode; } Task::none() } Message::WblockBlockName(name) => { if let Some(state) = self.wblock.as_mut() { state.block_name = name; } Task::none() } Message::WblockBlockSelect(name) => { if let Some(state) = self.wblock.as_mut() { state.block_name = name.clone(); let trimmed = name.trim(); if !trimmed.is_empty() { 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); 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(); } else { state.file_path = new_file_name; } } else { state.file_path = new_file_name; } } } } Task::none() } Message::WblockPickPoint => { self.active_modal = None; let cmd = crate::modules::insert::wblock::WblockPickBasePointCommand; self.command_line.push_info(&cmd.prompt()); self.tabs[self.active_tab].active_cmd = Some(Box::new(cmd)); Task::none() } Message::WblockBaseX(val) => { if let Some(state) = self.wblock.as_mut() { state.base_point_x = val; } Task::none() } Message::WblockBaseY(val) => { if let Some(state) = self.wblock.as_mut() { state.base_point_y = val; } Task::none() } Message::WblockBaseZ(val) => { if let Some(state) = self.wblock.as_mut() { state.base_point_z = val; } Task::none() } Message::WblockSelectObjects => { self.active_modal = None; use crate::modules::draw::select::SelectObjectsCommand; let cmd = SelectObjectsCommand::plain("WBLOCK", "WBLOCK_OBJECTS_GATHERED"); self.command_line.push_info(&cmd.prompt()); self.tabs[self.active_tab].active_cmd = Some(Box::new(cmd)); Task::none() } Message::WblockQuickSelect => { self.active_modal = None; self.on_qselect_open() } Message::WblockObjectMode(mode) => { if let Some(state) = self.wblock.as_mut() { state.object_mode = mode; } Task::none() } Message::WblockFilePath(path) => { if let Some(state) = self.wblock.as_mut() { state.file_path = path; } Task::none() } Message::WblockBrowsePath => { let default_name = if let Some(state) = self.wblock.as_ref() { let p = std::path::Path::new(&state.file_path); p.file_name() .and_then(|f| f.to_str()) .unwrap_or("new_block.dwg") .to_string() } else { "new_block.dwg".to_string() }; Task::perform( async move { let path = crate::sys::file_dialog() .set_title(crate::t!("Save Block As").as_ref()) .set_file_name(&default_name) .add_filter(crate::t!("DWG Files").as_ref(), &["dwg"]) .add_filter(crate::t!("DXF Files").as_ref(), &["dxf"]) .save_file() .await .map(|h| crate::sys::handle_path(&h)); path }, |path| Message::WblockBrowsePathResult(path), ) } Message::WblockBrowsePathResult(opt_path) => { if let Some(path) = opt_path { if let Some(state) = self.wblock.as_mut() { state.file_path = path.to_string_lossy().to_string(); } } Task::none() } Message::WblockUnit(unit) => { if let Some(state) = self.wblock.as_mut() { state.unit = unit; } Task::none() } Message::WblockDismissError => { if let Some(state) = self.wblock.as_mut() { state.error_message = None; } Task::none() } Message::WblockHelp => { self.command_line.push_info( crate::t!("WBLOCK writes objects, a block, or the entire drawing to a new drawing file.").as_ref(), ); Task::none() } Message::WblockApply => { let Some(state) = self.wblock.as_mut() else { return Task::none(); }; use crate::ui::window::wblock::{WblockObjectMode, WblockSourceMode}; match state.source_mode { 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(), ); return Task::none(); } let i = self.active_tab; 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(), ); return Task::none(); } } WblockSourceMode::Objects => { if state.selected_handles.is_empty() { state.error_message = Some( crate::t!("No objects selected. You must select objects to define a block.") .into_owned(), ); return Task::none(); } } WblockSourceMode::EntireDrawing => {} } let path_str = state.file_path.trim().to_string(); if path_str.is_empty() { state.error_message = Some(crate::t!("Please specify a file name and path.").into_owned()); return Task::none(); } let mut path = std::path::PathBuf::from(path_str); if path.extension().is_none() { path.set_extension("dwg"); } let i = self.active_tab; if let Some(current_path) = self.tabs[i].current_path.as_ref() { if current_path == &path { state.error_message = Some( crate::t!("Cannot write to the current drawing file.").into_owned(), ); return Task::none(); } } let state = self.wblock.take().unwrap(); self.active_modal = None; use crate::modules::insert::block_cmdline::WblockSource; use crate::ui::window::block_definition::BlockObjectMode; let mode = match state.object_mode { WblockObjectMode::Retain => BlockObjectMode::Retain, WblockObjectMode::Convert => BlockObjectMode::Convert, WblockObjectMode::Delete => BlockObjectMode::Delete, }; crate::modules::insert::block_cmdline::defaults().wblock_mode = mode; self.persist_settings_if_changed(); let source = match state.source_mode { WblockSourceMode::Block => { WblockSource::Block(state.block_name.trim().to_string()) } WblockSourceMode::EntireDrawing => WblockSource::Drawing, WblockSourceMode::Objects => WblockSource::Objects { handles: state.selected_handles.clone(), base: state.parse_base_point(), mode, }, }; self.wblock_write(i, source, path, state.unit) } Message::ToleranceDialogField(field) => { if let Some(state) = self.geometric_tolerance.as_mut() { state.apply_field(field); } Task::none() } Message::ToleranceDialogToggle(toggle) => { if let Some(state) = self.geometric_tolerance.as_mut() { state.apply_toggle(toggle); } Task::none() } Message::ToleranceDialogApply => { self.apply_tolerance_dialog_edit(); Task::none() } Message::ToleranceDialogOk => { let editing = self .geometric_tolerance .as_ref() .and_then(|state| state.editing) .is_some(); if editing { if self.apply_tolerance_dialog_edit() { self.geometric_tolerance = None; self.active_modal = None; self.reset_modal_geometry(); } } else { self.begin_tolerance_placement(); self.reset_modal_geometry(); } Task::none() } Message::SetLinearFormat(code) => { self.units_popup_open = false; let i = self.active_tab; if self.tabs[i].scene.document.header.linear_unit_format == code { return Task::none(); } // Every displayed length is written through this, so the whole // drawing re-reads at once — no geometry moves, only the way it // is written down. self.push_undo_snapshot(i, "LUNITS"); self.tabs[i].scene.document.header.linear_unit_format = code; self.tabs[i].dirty = true; 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, ), ); let label = crate::modules::draw::units::linear_format_label(code); self.command_line .push_output(crate::tf!("Length format is now {label}.").as_ref()); Task::none() } Message::SetDrawingUnits(code) => { self.units_popup_open = false; let i = self.active_tab; let current = self.tabs[i].scene.document.header.insertion_units; if current == code { return Task::none(); } // Relabelling only: the geometry keeps every number it had, and // now says they count something else. Say so, because picking a // unit and seeing the drawing sit still otherwise reads as the // menu having done nothing. (#668) self.push_undo_snapshot(i, "UNITS"); self.tabs[i].scene.document.header.insertion_units = code; self.tabs[i].scene.sync_annotation_scale_from_header(); self.tabs[i].scene.invalidate_annotation_dependencies(); self.tabs[i].dirty = true; let label = crate::modules::draw::units::label(code); self.command_line.push_output( crate::tf!( "Drawing unit is now {label}. Geometry unchanged — use DWGUNITS to convert it." ) .as_ref(), ); Task::none() } Message::ToggleIsolatePopup => { self.isolate_popup_open ^= true; Task::none() } Message::CloseIsolatePopup => { self.isolate_popup_open = false; Task::none() } Message::ToggleSnap(t) => { self.snapper.toggle(t); self.persist_settings_if_changed(); Task::none() } Message::ToggleSnapPopup => { if self.active_modal == Some(super::ModalKind::DraftingSettings) { if !self.drafting_settings_close_confirm && self.drafting_settings_dirty() { self.drafting_settings_close_confirm = true; return Task::none(); } self.close_active_modal(); self.snap_popup_open = false; } else { let st = crate::ui::window::drafting_settings::DraftingSettingsState::from_app(self); self.drafting_settings_saved = Some(st.clone()); self.drafting_settings_state = Some(st); self.drafting_settings_close_confirm = false; self.active_modal = Some(super::ModalKind::DraftingSettings); self.snap_popup_open = true; } Task::none() } Message::CloseSnapPopup => { self.snap_popup_open = false; if self.active_modal == Some(super::ModalKind::DraftingSettings) { self.close_active_modal(); } Task::none() } Message::SnapSelectAll => { self.snapper.enable_all(); Task::none() } Message::SnapClearAll => { self.snapper.disable_all(); Task::none() } // ── Drafting Settings Dialog ────────────────────────────────── Message::DraftingSettingsTabChanged(tab) => { if let Some(state) = &mut self.drafting_settings_state { state.active_tab = tab; } Task::none() } Message::DraftingSettingsToggleGrid => { if let Some(state) = &mut self.drafting_settings_state { state.grid_on = !state.grid_on; } Task::none() } Message::DraftingSettingsToggleSnap => { if let Some(state) = &mut self.drafting_settings_state { state.snap_on = !state.snap_on; } Task::none() } Message::DraftingSettingsSnapXChanged(value) => { if let Some(state) = &mut self.drafting_settings_state { state.snap_x_input = value.clone(); if state.snap_equal { state.snap_y_input = value; } } Task::none() } Message::DraftingSettingsSnapYChanged(value) => { if let Some(state) = &mut self.drafting_settings_state { state.snap_y_input = value.clone(); if state.snap_equal { state.snap_x_input = value; } } Task::none() } Message::DraftingSettingsGridXChanged(value) => { if let Some(state) = &mut self.drafting_settings_state { state.grid_x_input = value; } Task::none() } Message::DraftingSettingsGridYChanged(value) => { if let Some(state) = &mut self.drafting_settings_state { state.grid_y_input = value; } Task::none() } Message::DraftingSettingsGridMajorChanged(value) => { if let Some(state) = &mut self.drafting_settings_state { state.grid_major_input = value; } Task::none() } Message::DraftingSettingsToggleAdaptiveGrid => { if let Some(state) = &mut self.drafting_settings_state { state.grid_adaptive = !state.grid_adaptive; } Task::none() } Message::DraftingSettingsToggleBeyondLimits => { if let Some(state) = &mut self.drafting_settings_state { state.grid_beyond_limits = !state.grid_beyond_limits; } Task::none() } Message::DraftingSettingsToggleEqualSnap => { if let Some(state) = &mut self.drafting_settings_state { state.snap_equal = !state.snap_equal; if state.snap_equal { state.snap_y_input = state.snap_x_input.clone(); } } Task::none() } Message::DraftingSettingsToggleIsometric => { if let Some(state) = &mut self.drafting_settings_state { state.isometric = !state.isometric; } Task::none() } Message::DraftingSettingsSetIsoPlane(plane) => { if let Some(state) = &mut self.drafting_settings_state { state.isometric = true; state.iso_plane = plane; } Task::none() } Message::DraftingSettingsResetRotation => { if let Some(state) = &mut self.drafting_settings_state { state.snap_angle_deg = 0.0; } Task::none() } Message::DraftingSettingsTogglePolar => { if let Some(state) = &mut self.drafting_settings_state { state.polar_on = !state.polar_on; } Task::none() } Message::DraftingSettingsToggleOrtho => { if let Some(state) = &mut self.drafting_settings_state { state.ortho_on = !state.ortho_on; } Task::none() } Message::DraftingSettingsOsnapApertureChanged(value) => { if let Some(state) = &mut self.drafting_settings_state { state.osnap_aperture_input = value; } Task::none() } Message::DraftingSettingsToggleOsnap => { if let Some(state) = &mut self.drafting_settings_state { state.osnap_on = !state.osnap_on; } Task::none() } Message::DraftingSettingsToggleOtrack => { if let Some(state) = &mut self.drafting_settings_state { state.otrack_on = !state.otrack_on; } Task::none() } Message::DraftingSettingsToggleSnapMode(snap_type) => { if let Some(state) = &mut self.drafting_settings_state { if !state.snap_modes.remove(&snap_type) { state.snap_modes.insert(snap_type); } } Task::none() } Message::DraftingSettingsToggleSnapMode3d(snap_type) => { if let Some(state) = &mut self.drafting_settings_state { if !state.snap3d_modes.remove(&snap_type) { state.snap3d_modes.insert(snap_type); } } Task::none() } Message::DraftingSettingsSnapSelectAll => { if let Some(state) = &mut self.drafting_settings_state { for &(snap_type, _, _) in crate::snap::ALL_SNAP_MODES { state.snap_modes.insert(snap_type); } } Task::none() } Message::DraftingSettingsSnapClearAll => { if let Some(state) = &mut self.drafting_settings_state { state.snap_modes.clear(); } Task::none() } Message::DraftingSettingsToggle3dOsnap => { if let Some(state) = &mut self.drafting_settings_state { state.osnap3d_on = !state.osnap3d_on; } Task::none() } Message::DraftingSettingsToggleDynInput => { if let Some(state) = &mut self.drafting_settings_state { state.dyn_input_on = !state.dyn_input_on; } Task::none() } Message::DraftingSettingsToggleQuickProps => { if let Some(state) = &mut self.drafting_settings_state { state.quick_props_on = !state.quick_props_on; } Task::none() } Message::DraftingSettingsToggleSelCycling => { if let Some(state) = &mut self.drafting_settings_state { state.selection_cycling_on = !state.selection_cycling_on; } Task::none() } Message::DraftingSettingsApply => { if self.apply_drafting_settings() { self.drafting_settings_saved = self.drafting_settings_state.clone(); self.persist_settings_if_changed(); } Task::none() } Message::DraftingSettingsOk => { if self.apply_drafting_settings() { self.drafting_settings_saved = self.drafting_settings_state.clone(); self.drafting_settings_close_confirm = false; self.persist_settings_if_changed(); self.close_active_modal(); } Task::none() } Message::DraftingSettingsClose => { if !self.drafting_settings_close_confirm && self.drafting_settings_dirty() { self.drafting_settings_close_confirm = true; return Task::none(); } self.drafting_settings_close_confirm = false; self.close_active_modal(); Task::none() } Message::DraftingSettingsCloseDiscard => { self.drafting_settings_close_confirm = false; self.close_active_modal(); Task::none() } Message::DraftingSettingsCloseKeep => { self.drafting_settings_close_confirm = false; Task::none() } Message::AutoConstrainSelectRow(index) => { if index < self.auto_constrain_settings.priority.len() { self.auto_constrain_selected_row = index; } Task::none() } Message::AutoConstrainToggleKind(kind) => { if let Some(index) = self .auto_constrain_settings .enabled .iter() .position(|candidate| *candidate == kind) { self.auto_constrain_settings.enabled.remove(index); } else { self.auto_constrain_settings.enabled.push(kind); } Task::none() } Message::AutoConstrainMoveUp => { let index = self.auto_constrain_selected_row; if index > 0 && index < self.auto_constrain_settings.priority.len() { self.auto_constrain_settings.priority.swap(index, index - 1); self.auto_constrain_selected_row -= 1; } Task::none() } Message::AutoConstrainMoveDown => { let index = self.auto_constrain_selected_row; if index + 1 < self.auto_constrain_settings.priority.len() { self.auto_constrain_settings.priority.swap(index, index + 1); self.auto_constrain_selected_row += 1; } Task::none() } Message::AutoConstrainSelectAll => { self.auto_constrain_settings.enabled = super::settings::AutoConstraintKind::ALL.to_vec(); Task::none() } Message::AutoConstrainClearAll => { self.auto_constrain_settings.enabled.clear(); Task::none() } Message::AutoConstrainReset => { 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); self.auto_constrain_angle_input = format!("{}", self.auto_constrain_settings.angle_tolerance_deg); Task::none() } Message::AutoConstrainToggleTangentPoint => { self.auto_constrain_settings.tangent_must_share_point = !self.auto_constrain_settings.tangent_must_share_point; Task::none() } Message::AutoConstrainTogglePerpendicularIntersection => { self.auto_constrain_settings.perpendicular_must_intersect = !self.auto_constrain_settings.perpendicular_must_intersect; Task::none() } Message::AutoConstrainDistanceChanged(value) => { self.auto_constrain_distance_input = value; Task::none() } Message::AutoConstrainAngleChanged(value) => { self.auto_constrain_angle_input = value; Task::none() } action @ (Message::AutoConstrainApply | Message::AutoConstrainOk) => { let distance = self.auto_constrain_distance_input.trim().parse::(); let angle = self.auto_constrain_angle_input.trim().parse::(); match (distance, angle) { (Ok(distance), Ok(angle)) if distance.is_finite() && distance >= 0.0 && angle.is_finite() && angle >= 0.0 => { 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.persist_settings_if_changed(); if matches!(action, Message::AutoConstrainOk) { self.close_active_modal(); } } _ => self.command_line.push_error( crate::t!("Distance and angle tolerances must be non-negative numbers.") .as_ref(), ), } Task::none() } Message::AutoConstrainCancel => { if let Some(saved) = self.auto_constrain_saved.take() { self.auto_constrain_settings = saved; } self.close_active_modal(); Task::none() } // ── Ribbon dropdowns ────────────────────────────────────────── Message::ToggleRibbonDropdown(id) => { if self.tabs[self.active_tab].is_start { self.ribbon.close_dropdown(); } else { if id == crate::modules::insert::insert_block::GALLERY_ID { self.refresh_block_palette(); } // The per-viewport freeze column follows the active viewport. if id == crate::ui::ribbon::widgets::LAYER_COMBO_ID { self.refresh_layer_panel(); } self.ribbon.toggle_dropdown(&id); } Task::none() } Message::ToggleRibbonPanel(id) => { if self.tabs[self.active_tab].is_start { self.ribbon.close_dropdown(); } else { self.ribbon.toggle_collapsed_panel(&id); } Task::none() } Message::CloseRibbonDropdown => { self.ribbon.close_dropdown(); Task::none() } Message::XrefFadeSlide(amount) => { let sign = if self.ribbon.xref_fade < 0 { -1 } else { 1 }; self.ribbon.xref_fade = sign * amount as i32; Task::none() } Message::XrefFadeCommit => { self.set_xref_fade(self.ribbon.xref_fade); Task::none() } Message::XrefFadeToggle => { let fade = crate::scene::cache::block_cache::xref_fade_ctl(); self.set_xref_fade(if fade == 0 { 50 } else { -fade }); Task::none() } Message::DropdownSelectItem { dropdown_id, cmd } => { if self.tabs[self.active_tab].is_start { self.ribbon.close_dropdown(); return Task::none(); } // Save As is a secondary operation; the save face keeps BSAVE. let face = if dropdown_id == "BLOCK_SAVE" { "BSAVE" } else { cmd }; self.ribbon.select_dropdown_item(dropdown_id, face); self.ribbon.activate_tool(cmd); self.dispatch_ribbon_command(cmd) } Message::DeleteSelected => { // In the MText preview, Delete removes text at the caret. if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { self.mtext_delete(); return Task::none(); } let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().menu.open_at = None; // A selected constraint-glyph pill takes Delete before entity // erase — the two selections are mutually exclusive (see // `Scene::selected_constraint`). if let Some(id) = self.tabs[i].scene.selected_constraint { self.delete_parametric_constraint(id); return Task::none(); } let handles: Vec<_> = self.tabs[i].scene.selected.iter().cloned().collect(); if !handles.is_empty() { // Erase is delta-safe unless a target is in a group (group // cleanup rewrites document.objects). let delta_safe = self.delta_erase_safe(i, &handles); let pending = self.begin_undo(i, "ERASE", handles.len(), delta_safe); // Stash the erased entities so OOPS can restore them. self.oops_cache = handles .iter() .filter_map(|h| self.tabs[i].scene.document.get_entity_arc(*h)) .collect(); self.tabs[i].scene.erase_entities(&handles); self.tabs[i].dirty = true; self.refresh_properties(); if let Some(pd) = pending { self.commit_undo_delta(i, pd); } } Task::none() } Message::SetModifiers { shift, ctrl } => { let ctrl_changed = self.ctrl_down != ctrl; self.shift_down = shift; self.ctrl_down = ctrl; // Releasing Shift drops the hard axis lock immediately (#312) // — without this a lock could linger until the next move. if !shift { self.axis_lock_dir = None; } // A live command may key its preview off Ctrl (arc-direction // flip). Rebuild it at the current cursor so the flip shows // without waiting for the next mouse move. let i = self.active_tab; if ctrl_changed && self.tabs[i].active_cmd.is_some() { let p = self.tabs[i].last_cursor_screen; return Task::done(Message::ViewportMove(p)); } Task::none() } // ── In-place MText editor ─────────────────────────────────── Message::MTextEdit(action) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.content.perform(action); } self.rebuild_mtext_preview(); Task::none() } Message::MTextFmt(kind) => { self.mtext_apply_fmt(kind); Task::none() } Message::MTextHeight(s) => { self.mtext_apply_span_number("height", s); Task::none() } Message::MTextRectWidth(width) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.rect_width = width.max(1e-6); } self.rebuild_mtext_preview(); Task::none() } Message::MTextColorChanged(color) => { self.mtext_apply_color(color); Task::none() } Message::MTextColorPickerToggle => { if let Some(ed) = self.mtext_editor.as_mut() { ed.color_picker_open = !ed.color_picker_open; } Task::none() } Message::MTextStyle(s) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.style = s; } self.rebuild_mtext_preview(); Task::none() } Message::MTextFont(f) => { self.mtext_apply_font(&f); Task::none() } Message::MTextOblique(s) => { self.mtext_apply_span_number("oblique", s); Task::none() } Message::MTextWidth(s) => { self.mtext_apply_span_number("width", s); Task::none() } Message::MTextCharSpace(s) => { self.mtext_apply_span_number("tracking", s); Task::none() } Message::MTextUndo => { self.mtext_undo(); Task::none() } Message::MTextRedo => { self.mtext_redo(); Task::none() } Message::MTextStack => { self.mtext_stack_selection(); Task::none() } Message::MTextClearFormatting => { self.mtext_clear_formatting(); Task::none() } Message::MTextInsert(value) => { self.mtext_type(&value); Task::none() } Message::MTextAnnotative(value) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.annotative = value; } self.rebuild_mtext_preview(); Task::none() } Message::MTextColumnMode(mode) => { if let Some(ed) = self.mtext_editor.as_mut() { match mode.as_str() { "Static" => { ed.column_type = 1; ed.column_auto_height = false; } "Dynamic auto" => { ed.column_type = 2; ed.column_auto_height = true; } "Dynamic manual" => { ed.column_type = 2; ed.column_auto_height = false; } _ => ed.column_type = 0, } } self.rebuild_mtext_preview(); Task::none() } Message::MTextColumnCount(value) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.column_count = value; } self.rebuild_mtext_preview(); Task::none() } Message::MTextColumnWidth(value) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.column_width = value; } self.rebuild_mtext_preview(); Task::none() } Message::MTextColumnGutter(value) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.column_gutter = value; } self.rebuild_mtext_preview(); Task::none() } Message::MTextColumnHeight(value) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.rect_height = value; } self.rebuild_mtext_preview(); Task::none() } Message::MTextColumnFlowReversed(value) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.column_flow_reversed = value; } self.rebuild_mtext_preview(); Task::none() } Message::MTextParagraphNumber(field, value) => { self.mtext_apply_paragraph_number(field, value); Task::none() } Message::MTextFindText(value) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.find_text = value; } Task::none() } Message::MTextReplaceText(value) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.replace_text = value; } Task::none() } Message::MTextFindNext => { self.mtext_find_next(); Task::none() } Message::MTextReplaceNext => { self.mtext_replace_next(); Task::none() } Message::MTextReplaceAll => { self.mtext_replace_all(); Task::none() } Message::MTextJustify(ap) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.attachment = ap; } self.rebuild_mtext_preview(); Task::none() } Message::MTextAlign(a) => { self.mtext_apply_align(a); Task::none() } Message::MTextLineSpacing(f) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.line_spacing = f; } self.rebuild_mtext_preview(); Task::none() } Message::MTextShowPreview(on) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.show_preview = on; } self.rebuild_mtext_preview(); // Focus the text area when switching to Edit so the caret // shows and typing/clicking edits immediately. if on { Task::none() } else { iced::widget::operation::focus(iced::widget::Id::new( super::view::MTEXT_TEXT_ID, )) } } Message::MTextSelStart(off) => { // Count quick same-spot clicks: 1 = place caret, 2 = select the // word, 3 = select all. let now = Instant::now(); let count = match self.mtext_click_time { Some(t) if now.duration_since(t).as_millis() < 400 && off.abs_diff(self.mtext_click_off) <= 1 => { (self.mtext_click_count + 1).min(3) } _ => 1, }; self.mtext_click_time = Some(now); self.mtext_click_off = off; self.mtext_click_count = count; match count { 2 => self.mtext_select_word(off), 3 => self.mtext_select_all(), _ => { if let Some(ed) = self.mtext_editor.as_mut() { ed.sel_anchor = off; ed.sel = Some((off, off)); ed.caret = off; ed.caret_blink_on = true; } } } self.unfocus_widgets() } Message::MTextSelTo(off) => { if let Some(ed) = self.mtext_editor.as_mut() { let a = ed.sel_anchor; ed.sel = Some((a.min(off), a.max(off))); ed.caret = off; ed.caret_blink_on = true; } Task::none() } Message::MTextCaretMove(d) => { if self.tabs[self.active_tab] .properties .hatch_pattern_picker_open { return self.update(Message::PropHatchPatternNavigate(d as i8)); } self.mtext_caret_move(d, false); Task::none() } Message::MTextCaretBlink => { if let Some(ed) = self.mtext_editor.as_mut() { ed.caret_blink_on = !ed.caret_blink_on; } Task::none() } Message::MTextOk => { let committed = self.mtext_commit(); self.post_editor_closed(committed) } Message::MTextApply => { self.mtext_apply(); Task::none() } Message::MTextCancel => { self.mtext_cancel(); self.post_editor_closed(false) } Message::TextInlineInput(s) => { if let Some(ed) = self.text_inline.as_mut() { ed.value = s; } Task::none() } // Ctrl+V. The MText editor and (on the web) the TEXT editor read the // system clipboard asynchronously — the only paste path that works // in the browser, where the synchronous clipboard the iced // text_input expects is empty. With no editor open, drawing objects // take priority and system text is used when that clipboard is empty. Message::PasteShortcut => self.on_paste_shortcut(), // A focused text input pastes text natively: another field keeps // Ctrl+V to itself, and the command line must not get the text a // second time. Copied objects still paste over the command line. Message::PasteShortcutResolved(focus) => { use super::PasteFocus; if focus == PasteFocus::Field { Task::none() } else if !self.clipboard.is_empty() { self.update(Message::Command("PASTECLIP".to_string())) } else if focus == PasteFocus::CommandLine { Task::none() } else { self.read_system_clipboard_for_paste() } } Message::PasteSpecial(text) => { let i = self.active_tab; let waiting = self.tabs[i] .active_cmd .as_ref() .is_some_and(|command| command.name() == "PASTESPEC"); if !waiting { return Task::none(); } match text.filter(|text| !text.trim().is_empty()) { Some(text) => { // Same prompt, one more option: swap without echoing it. self.tabs[i].active_cmd = Some(self.paste_special_command(i, Some(&text))); self.push_ucs_to_cmd(i); Task::none() } // Nothing but maybe an image: paste it as one. None if self.clipboard.is_empty() => { self.tabs[i].active_cmd = None; self.read_system_clipboard_image() } None => Task::none(), } } Message::SystemClipboardPaste(result) => { use super::SystemClipboardText as Text; match result { Text::Text(text) => { let flat = text.replace(['\r', '\n'], " ").to_uppercase(); self.command_line.input.push_str(&flat); self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); self.focus_cmd_input() } Text::EmptyOrUnsupported => { // No text — the clipboard may still hold an IMAGE (a // pasted screenshot). Read it and attach it the way the // IMAGE command's pick does; only when that also finds // nothing does the unsupported-content error surface. self.read_system_clipboard_image() } Text::Unavailable => { self.command_line .push_error(crate::tr!("clipboard", "unavailable").as_ref()); Task::none() } Text::Occupied => { self.command_line .push_error(crate::tr!("clipboard", "occupied").as_ref()); Task::none() } Text::ConversionFailed => { self.command_line .push_error(crate::tr!("clipboard", "conversion-failed").as_ref()); Task::none() } } } // The clipboard-image fallback of a text-less paste either // attaches the image or falls back to the same error the text // path reports when nothing readable was on the clipboard. Message::SystemClipboardPasteImage(image) => match image { Some((name, bytes)) => self.paste_clipboard_image(name, bytes), None => { self.command_line .push_error(crate::tr!("clipboard", "no-supported-content").as_ref()); Task::none() } }, Message::SelectAllShortcut => { let i = self.active_tab; if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { // Ctrl+A in the MText editor selects all of its text. self.mtext_select_all(); Task::none() } else if self.dock_panel_visible(crate::ui::dock::PanelId::Layers) && self.tabs[i].layers.selected.is_some() { // Select every row in the Layer Manager (#236) while the // user is working in it (a layer row is selected). let n = self.tabs[i].layers.layers.len(); self.tabs[i].layers.selected_multi = (0..n).collect(); self.tabs[i].layers.selected = (n > 0).then_some(0); Task::none() } else { self.dispatch_command("SELECTALL") } } Message::FindReplaceOpen => self.open_find_replace(), Message::FindReplaceSearchChanged(value) => { self.find_replace_search_changed(value); Task::none() } Message::FindReplaceReplacementChanged(value) => { self.find_replace_replacement_changed(value); Task::none() } Message::FindReplaceNext => { self.find_replace_next(); Task::none() } Message::FindReplaceOne => { self.find_replace_one(); Task::none() } Message::FindReplaceAll => { self.find_replace_all(); Task::none() } Message::MTextPasteClip(text) => { if let Some(text) = text.filter(|t| !t.is_empty()) { // CR/LF arrive as line breaks; MText keeps "\n", drop "\r". self.mtext_type(&text.replace('\r', "")); self.rebuild_mtext_preview(); } Task::none() } Message::TextInlinePasteClip(text) => { if let Some(text) = text.filter(|t| !t.is_empty()) { // Single-line field: collapse newlines, append at the end. let flat = text.replace(['\r', '\n'], " "); if let Some(ed) = self.text_inline.as_mut() { ed.value.push_str(&flat); } } Task::none() } Message::TextInlineOk => { let committed = self.text_inline_commit(); self.post_editor_closed(committed) } Message::ContextMenuPick(action) => self.on_context_menu_pick(action), Message::ContextMenuSubmenuToggle(id) => self.on_context_menu_submenu_toggle(id), Message::ContextMenuNavigate(nav) => self.on_context_menu_navigate(nav), Message::DrawOrderPickRef(above) => { let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().menu.open_at = None; let to_move: Vec<_> = self.tabs[i].scene.selected.iter().cloned().collect(); if to_move.is_empty() { self.command_line .push_error(crate::t!("DRAWORDER: select entities first.").as_ref()); } else { use crate::command::CadCommand; let cmd = super::commands::DrawOrderCommand::for_reference_pick(to_move, above); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } Task::none() } Message::SelectSimilar => { let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().menu.open_at = None; let added = self.tabs[i].scene.select_similar(); self.command_line .push_output(crate::tf!("Select Similar: {} added.", added).as_ref()); self.refresh_properties(); Task::none() } Message::InvertSelection => { let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().menu.open_at = None; let count = self.tabs[i].scene.invert_selection(); self.command_line.push_output( crate::tf!("Invert Selection: {} object(s) selected.", count).as_ref(), ); self.refresh_properties(); Task::none() } Message::QSelectOpen => self.on_qselect_open(), Message::QSelectClose => { if let Some(state) = self.qselect.take() { self.qselect_settings = Some((&state).into()); } self.reset_modal_geometry(); if self.block_definition.is_some() { self.active_modal = Some(super::ModalKind::BlockDefinition); } else if self.wblock.is_some() { self.active_modal = Some(super::ModalKind::WriteBlock); } Task::none() } Message::QSelectSetScope(scope) => { let i = self.active_tab; let available_types = self.tabs[i].scene.qselect_entity_type_names(scope); let type_filter = self.qselect.as_ref().and_then(|state| { state .type_filter .as_ref() .filter(|selected| available_types.iter().any(|item| item == *selected)) .cloned() }); let available_properties = self.tabs[i] .scene .qselect_properties(type_filter.as_deref(), scope); let candidate_count = self.tabs[i].scene.qselect_candidate_count(scope); if let Some(state) = self.qselect.as_mut() { state.scope = scope; state.available_types = available_types; state.available_properties = available_properties; state.candidate_count = candidate_count; state.type_filter = type_filter; state.property = state.property.as_ref().and_then(|selected| { state .available_properties .iter() .find(|available| available.field == selected.field) .cloned() }); state.value.clear(); state.error = None; if matches!(scope, crate::app::QSelectScope::CurrentSelection) { state.append = false; } state.normalize_operator(); } Task::none() } Message::QSelectSetType(t) => { let i = self.active_tab; let scope = self .qselect .as_ref() .map_or(crate::app::QSelectScope::CurrentSpace, |state| state.scope); let properties = self.tabs[i].scene.qselect_properties(t.as_deref(), scope); if let Some(state) = self.qselect.as_mut() { let kept_property = state.property.as_ref().and_then(|selected| { properties .iter() .find(|available| available.field == selected.field) .cloned() }); state.type_filter = t; state.available_properties = properties; state.property = kept_property; state.value.clear(); state.error = None; state.normalize_operator(); } Task::none() } Message::QSelectSetProperty(p) => { if let Some(state) = self.qselect.as_mut() { state.property = p; state.value.clear(); state.error = None; state.normalize_operator(); } Task::none() } Message::QSelectSetOperator(op) => { if let Some(state) = self.qselect.as_mut() { state.operator = op; state.error = None; } Task::none() } Message::QSelectSetValue(v) => { if let Some(state) = self.qselect.as_mut() { state.value = v; state.error = None; } Task::none() } Message::QSelectSetMode(mode) => { if let Some(state) = self.qselect.as_mut() { state.mode = mode; state.error = None; } Task::none() } Message::QSelectSetAppend(b) => { if let Some(state) = self.qselect.as_mut() { if matches!(state.scope, crate::app::QSelectScope::CurrentSpace) { state.append = b; } state.error = None; } Task::none() } Message::QSelectPickObject => self.on_qselect_pick_object(), Message::QSelectAddCondition => { if let Some(state) = self.qselect.as_mut() { let condition = state.draft_condition(); if let Some(error) = condition.validation_error() { state.error = Some(error); return Task::none(); } if let Some(index) = state.editing_condition.take() { if let Some(existing) = state.conditions.get_mut(index) { *existing = condition; } } else { state.conditions.push(condition); } state.error = None; } Task::none() } Message::QSelectEditCondition(index) => { let condition = self .qselect .as_ref() .and_then(|state| state.conditions.get(index).cloned()); if let Some(condition) = condition { let scope = self.qselect.as_ref().unwrap().scope; let properties = self.tabs[self.active_tab] .scene .qselect_properties(condition.type_filter.as_deref(), scope); if let Some(state) = self.qselect.as_mut() { state.available_properties = properties; state.type_filter = condition.type_filter; state.property = condition.property; state.operator = condition.operator; state.value = condition.value; state.editing_condition = Some(index); state.error = None; } } Task::none() } Message::QSelectRemoveCondition(index) => { if let Some(state) = self.qselect.as_mut() { if index < state.conditions.len() { state.conditions.remove(index); state.editing_condition = match state.editing_condition { Some(editing) if editing == index => None, Some(editing) if editing > index => Some(editing - 1), other => other, }; } state.error = None; } Task::none() } Message::QSelectClearConditions => { if let Some(state) = self.qselect.as_mut() { state.conditions.clear(); state.editing_condition = None; state.error = None; } Task::none() } Message::QSelectCancelEdit => { if let Some(state) = self.qselect.as_mut() { state.editing_condition = None; state.error = None; } Task::none() } Message::QSelectApply => { let validation_error = self.qselect.as_ref().and_then(|state| { if self.tabs[self.active_tab] .scene .qselect_candidate_count(state.scope) == 0 { Some(crate::t!("No objects are available in this scope.").into_owned()) } else if state.editing_condition.is_some() { Some( crate::t!("Update or cancel the condition edit before applying.") .into_owned(), ) } else if state.conditions.is_empty() { state.draft_condition().validation_error() } else { state .conditions .iter() .find_map(|condition| condition.validation_error()) } }); if let Some(error) = validation_error { if let Some(state) = self.qselect.as_mut() { state.error = Some(error); } return Task::none(); } let Some(state) = self.qselect.take() else { return Task::none(); }; self.qselect_settings = Some((&state).into()); self.reset_modal_geometry(); let i = self.active_tab; let conditions = if state.conditions.is_empty() { vec![state.draft_condition()] } else { state.conditions.clone() }; let matched = self.tabs[i].scene.qselect_conditions( state.scope, &conditions, state.mode, state.append && matches!(state.scope, crate::app::QSelectScope::CurrentSpace), ); self.command_line .push_output(crate::tf!("QSELECT: {} object(s) selected.", matched).as_ref()); self.refresh_properties(); if let Some(ref mut block_def) = self.block_definition { block_def.selected_handles = self.tabs[i] .scene .selected_entities() .into_iter() .map(|(h, _)| h) .collect(); block_def.error_message = None; self.active_modal = Some(super::ModalKind::BlockDefinition); } else if let Some(ref mut wblock) = self.wblock { wblock.selected_handles = self.tabs[i] .scene .selected_entities() .into_iter() .map(|(h, _)| h) .collect(); wblock.error_message = None; self.active_modal = Some(super::ModalKind::WriteBlock); } else { // FILTER may be opened from a still-focused command input. // Release text focus so Delete and clipboard shortcuts act // on the resulting pick-first selection, not on that input. return self.unfocus_widgets(); } Task::none() } // ── Properties panel messages ───────────────────────────────── Message::PropSelectionGroupChanged(group) => { self.tabs[self.active_tab].properties.selected_group = Some(group); self.refresh_properties(); Task::none() } Message::RibbonLayerChanged(layer) => self.on_ribbon_layer_changed(layer), Message::RibbonColorChanged(color) => self.on_ribbon_color_changed(color), Message::RibbonColorPaletteToggle => { self.ribbon.prop_color_palette_open ^= true; Task::none() } Message::RibbonLinetypeChanged(lt) => self.on_ribbon_linetype_changed(lt), Message::RibbonLineweightChanged(lw) => self.on_ribbon_lineweight_changed(lw), Message::RibbonStyleChanged { key, name } => self.on_ribbon_style_changed(key, name), Message::PropLayerChanged(layer) => { let i = self.active_tab; let handles = self.property_target_handles(i); if handles.is_empty() { let task = self.on_ribbon_layer_changed(layer); self.refresh_properties(); return task; } 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_common_prop(entity, "layer", &layer); } }); Task::none() } Message::PropColorChanged(color) => self.set_property_color(color, None), Message::PropLwChanged(lw) => { let i = self.active_tab; let handles = self.property_target_handles(i); if handles.is_empty() { if self.has_property_selection(i) { return Task::none(); } self.tabs[i].scene.document.header.current_line_weight = lw.value(); self.tabs[i].dirty = true; self.ribbon.active_lineweight = lw; self.refresh_properties(); return Task::none(); } 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); } }); Task::none() } Message::PropFieldLwChanged { field, value } => { let i = self.active_tab; let handles = self.property_target_handles(i); self.apply_property_op(i, "CHPROP", &handles, |app, handle| { if let Some(codec::EntityType::MultiLeader(leader)) = app.tabs[i].scene.document.get_entity_mut(handle) { if field == "line_weight" { leader.line_weight = value; leader.property_override_flags.insert( codec::entities::MultiLeaderPropertyOverrideFlags::LEADER_LINE_WEIGHT, ); for root in &mut leader.context.leader_roots { for line in &mut root.lines { line.line_weight = value; line.override_flags.insert( codec::entities::LeaderLinePropertyOverrideFlags::LINE_WEIGHT, ); } } } } }); Task::none() } Message::PropLinetypeChanged(lt) => { let i = self.active_tab; let handles = self.property_target_handles(i); if handles.is_empty() { let task = self.on_ribbon_linetype_changed(lt); self.refresh_properties(); return task; } 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_common_prop(entity, "linetype", <); } }); Task::none() } Message::PropHatchPatternChanged(name) => { let panel = &mut self.tabs[self.active_tab].properties; panel.hatch_pattern_picker_open = false; panel.hatch_pattern_search.clear(); self.on_prop_hatch_pattern_changed(name) } Message::PropHatchPatternPickerToggle(current) => { let panel = &mut self.tabs[self.active_tab].properties; panel.hatch_pattern_picker_open = !panel.hatch_pattern_picker_open; if panel.hatch_pattern_picker_open { panel.color_picker_open = false; panel.open_color_field = None; panel.edit_choice_open = false; panel.hatch_pattern_focus = crate::ui::properties::filtered_hatch_patterns("") .iter() .position(|entry| entry.name.eq_ignore_ascii_case(¤t)) .unwrap_or(0); return iced::widget::operation::focus(iced::widget::Id::new( "hatch-pattern-search", )); } else { panel.hatch_pattern_search.clear(); panel.hatch_pattern_focus = 0; } Task::none() } Message::PropHatchPatternSearchChanged(search) => { let panel = &mut self.tabs[self.active_tab].properties; panel.hatch_pattern_search = search; panel.hatch_pattern_focus = 0; Task::none() } Message::PropHatchPatternFocus(index) => { let panel = &mut self.tabs[self.active_tab].properties; let len = crate::ui::properties::filtered_hatch_patterns(&panel.hatch_pattern_search) .len(); if index < len { panel.hatch_pattern_focus = index; } Task::none() } Message::PropHatchPatternNavigate(delta) => { let panel = &mut self.tabs[self.active_tab].properties; let len = crate::ui::properties::filtered_hatch_patterns(&panel.hatch_pattern_search) .len(); if len > 0 { panel.hatch_pattern_focus = (panel.hatch_pattern_focus as isize + delta as isize) .rem_euclid(len as isize) as usize; } Task::none() } Message::PropHatchPatternConfirm => { let panel = &self.tabs[self.active_tab].properties; let name = crate::ui::properties::filtered_hatch_patterns(&panel.hatch_pattern_search) .get(panel.hatch_pattern_focus) .map(|entry| entry.name.clone()); if let Some(name) = name { self.update(Message::PropHatchPatternChanged(name)) } else { Task::none() } } Message::PropBoolToggle(field) => { let i = self.active_tab; let handles = self.property_target_handles(i); if !handles.is_empty() { self.apply_property_op(i, "CHPROP", &handles, |app, handle| { match field { // Per-object annotative toggle: MTEXT/MULTILEADER carry // a native flag; single-line TEXT is annotative purely // by the presence of a per-object context. A doc-aware // toggle so turning it on synthesizes a real per-scale // representation and turning it off removes it (not just // the flag). "is_annotative" | "enable_annotation_scale" | "annotative_ctx" => { let doc = &app.tabs[i].scene.document; let cur = match doc.get_entity(handle) { Some(codec::EntityType::MText(t)) => t.is_annotative, Some(codec::EntityType::MultiLeader(m)) => { m.enable_annotation_scale } // TEXT (and any other context-only type): its // annotative state is whether a context exists. Some(e) => crate::scene::annotative::is_annotative(doc, e), None => return, }; crate::scene::annotative::set_entity_annotative( &mut app.tabs[i].scene.document, handle, !cur, ); // Turning it on also gives the object a real // per-scale representation at the current // annotation scale (not just the native flag), // so it interoperates as a genuine annotative // object. Off is handled inside set_entity_*. if !cur { if let Some(sh) = app.tabs[i].scene.creation_annotation_scale_handle() { crate::scene::annotative::create_annotation_context( &mut app.tabs[i].scene.document, handle, sh, ); } } } "invisible" => { if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::toggle_invisible(entity); } } // Uniform-scale checkbox on a block reference // (#427): checking collapses Y/Z onto X; unchecking // only switches the panel to per-axis rows. "ins_uniform" => { let scales = match app.tabs[i].scene.document.get_entity(handle) { Some(codec::EntityType::Insert(ins)) => { Some((ins.x_scale(), ins.y_scale(), ins.z_scale())) } _ => None, }; let Some((sx, sy, sz)) = scales else { return }; let eq = (sx - sy).abs() < 1e-12 && (sx - sz).abs() < 1e-12; let checked = eq && !app.tabs[i].props_asym_scale.contains(&handle.value()); if checked { app.tabs[i].props_asym_scale.insert(handle.value()); } else { 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) { ins.set_y_scale(sx); ins.set_z_scale(sx); } } } "tbl_title_suppressed" | "tbl_header_suppressed" => { let next = { let document = &app.tabs[i].scene.document; let Some(codec::EntityType::Table(table)) = document.get_entity(handle) else { return; }; let table_style = table.table_style_handle.and_then(|style_handle| { document.objects.get(&style_handle).and_then(|object| { match object { codec::objects::ObjectType::TableStyle( style, ) => Some(style), _ => None, } }) }); let current = if field == "tbl_title_suppressed" { crate::entities::table::resolved_title_suppressed( table, table_style, ) } else { crate::entities::table::resolved_header_suppressed( table, table_style, ) }; !current }; if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_geom_prop( entity, field, if next { "true" } else { "false" }, ); } } _ => { if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_geom_prop( entity, field, "toggle", ); } } } if field.starts_with("tbl_") { if let Some(codec::EntityType::Table(table)) = app.tabs[i].scene.document.get_entity_mut(handle) { table.block_record_handle = None; } } }); } Task::none() } Message::PropVertexStep(delta) => { let i = self.active_tab; let handles = self.property_target_handles(i); // Navigate the active vertex or table cell. let n = if handles.len() == 1 { match self.tabs[i].scene.document.get_entity(handles[0]) { Some(codec::EntityType::LwPolyline(p)) => p.vertices.len(), Some(codec::EntityType::Polyline2D(p)) => p.vertices.len(), Some(codec::EntityType::PolygonMesh(p)) => p.vertices.len(), Some(codec::EntityType::Face3D(face)) => { if face.is_triangle() { 3 } else { 4 } } Some(codec::EntityType::Polyline3D(p)) => { crate::entities::polyline::polyline3d_control_vertex_count(p) } Some(codec::EntityType::Table(table)) => { table.row_count().saturating_mul(table.column_count()) } _ => 0, } } else { 0 }; if n > 0 { let cur = self.tabs[i].properties.prop_vertex.min(n - 1) as i64; // Wrap around so ◀ from the first vertex lands on the last. let next = (cur + delta as i64).rem_euclid(n as i64) as usize; self.tabs[i].properties.prop_vertex = next; self.tabs[i].properties.prop_vertex_indicator_active = next != cur as usize; crate::entities::table::set_prop_current_cell(next); self.refresh_properties(); } Task::none() } Message::PropGeomChoiceChanged { field, value } => { self.on_prop_geom_choice_changed(field, value) } Message::PropGeomInput { field, value } => { self.tabs[self.active_tab] .properties .edit_buf .insert(crate::ui::properties::FieldKey::Geom(field), value); Task::none() } Message::PropGeomCommit(field) => self.on_prop_geom_commit(field), Message::PropGroupToggle(key) => { let groups = &mut self.tabs[self.active_tab].properties.expanded_groups; if !groups.remove(&key) { groups.insert(key); } Task::none() } Message::PropSectionToggle(title) => { let sections = &mut self.collapsed_property_sections; if !sections.remove(&title) { sections.insert(title); } // Keep already-built panels in other open documents in sync, // rather than waiting for each one to rebuild on selection. let sections = self.collapsed_property_sections.clone(); for tab in &mut self.tabs { tab.properties.collapsed_sections = sections.clone(); } Task::none() } Message::PropEditChoiceToggle => { let panel = &mut self.tabs[self.active_tab].properties; panel.edit_choice_open = !panel.edit_choice_open; if panel.edit_choice_open { panel.hatch_pattern_picker_open = false; panel.hatch_pattern_search.clear(); } Task::none() } Message::PropAttrInput { tag, value } => { self.tabs[self.active_tab] .properties .edit_buf .insert(crate::ui::properties::attr_edit_key(&tag), value); Task::none() } Message::PropAttrCommit(tag) => self.on_prop_attr_commit(tag), Message::PropPointerPressed => { let mut rename_blur = if self.tabs[self.active_tab].layers.editing.is_some() { crate::ui::properties::focused_widget_task(Message::LayerRenameFocus) } else { Task::none() }; if self.style_rename.is_some() { rename_blur = Task::batch([ rename_blur, crate::ui::properties::focused_widget_task(Message::StyleRenameFocus), ]); } if !self.dock_panel_visible(crate::ui::dock::PanelId::Properties) && !self.dock_panel_visible(crate::ui::dock::PanelId::GraphicAttributes) { return rename_blur; } Task::batch([crate::ui::properties::sync_active_field_task(), rename_blur]) } Message::PropSyncActive(focused) => { // The Graphic Attributes linetype scale field behaves like a // Properties field: the first click selects its value. let scale_field = iced::widget::Id::new( crate::ui::window::graphic_attributes::LINETYPE_SCALE_FIELD, ); if focused.as_ref() == Some(&scale_field) { if !std::mem::replace(&mut self.graphic_attributes.linetype_scale_focused, true) { return iced::widget::operation::select_all(scale_field); } return Task::none(); } self.graphic_attributes.linetype_scale_focused = false; self.graphic_attributes.linetype_scale_input = None; let panel = &mut self.tabs[self.active_tab].properties; if let Some(id) = focused.as_ref() { if let Some(key) = panel.prop_field_key_for_id(id) { let changed = panel.active_field.as_ref() != Some(&key); panel.active_field = Some(key); // Only select the whole value when focus landed on a // field that wasn't already the active one; re-focusing // the same field (or sweeping after a click elsewhere // left its focus in place) must keep caret placement. if changed { return iced::widget::operation::select_all(id.clone()); } return Task::none(); } } if !crate::ui::properties::active_key_focused( panel.active_field.as_ref(), focused.as_ref(), ) { panel.active_field = None; } Task::none() } Message::PropPickerSurface(quick, message) => { let task = self.update(*message); let i = self.active_tab; self.tabs[i].properties.picker_in_quick = quick; task } Message::PropColorPickerToggle => { let i = self.active_tab; self.tabs[i].properties.color_picker_open = !self.tabs[i].properties.color_picker_open; if self.tabs[i].properties.color_picker_open { self.tabs[i].properties.hatch_pattern_picker_open = false; self.tabs[i].properties.hatch_pattern_search.clear(); } Task::none() } Message::PropBgColorPickerToggle => { let i = self.active_tab; self.tabs[i].properties.bg_color_picker_open = !self.tabs[i].properties.bg_color_picker_open; Task::none() } Message::PropBgColorChanged(color) => { let i = self.active_tab; let handles = self.property_target_handles(i); if !handles.is_empty() { self.apply_property_op(i, "CHPROP", &handles, |app, handle| { match app.tabs[i].scene.document.get_entity_mut(handle) { Some(codec::EntityType::MText(m)) => { m.background_color = color.clone(); // Picking a colour turns the background on in Fill // mode (specific colour), preserving the frame bit. m.background_fill_flags = (m.background_fill_flags & !0x02) | 0x01; } Some(codec::EntityType::Hatch(h)) => { crate::entities::hatch::set_background_color(h, &color); } _ => {} } }); self.tabs[i].properties.bg_color_picker_open = false; } Task::none() } Message::PropColorFieldToggle(field) => { let i = self.active_tab; let p = &mut self.tabs[i].properties; p.open_color_field = if p.open_color_field.as_deref() == Some(field.as_str()) { None } else { Some(field) }; Task::none() } Message::PropColorFieldChanged { field, color } => { let i = self.active_tab; let handles = self.property_target_handles(i); if field == "indicator_fill_color" { self.apply_property_op(i, "CHPROP", &handles, |app, handle| { if let Some(codec::EntityType::Extended(extended)) = app.tabs[i].scene.document.get_entity_mut(handle) { if let codec::entities::ExtendedEntityData::SectionObject(data) = &mut extended.data { data.indicator_color = color; } } }); self.tabs[i].properties.open_color_field = None; return Task::none(); } // Dim-line colour override (Leader / Dimension): write it as an // ACAD_DSTYLE code-176 override (an ACI index) so it round-trips // through DWG and DXF. RGB picks collapse to the nearest ACI, in // line with the rest of the dim-colour stack (index-only through // the file layer). Guarded to leaders / dimensions so a mixed // selection can't stamp the override onto other entities. if matches!( field.as_str(), "dim_line_color" | "dim_ext_line_color" | "dim_text_color" | "dim_text_fill_color" ) { let fill_mode = (field == "dim_text_fill_color").then(|| match color { codec::types::Color::None => 0, codec::types::Color::ByBlock => 1, _ => 2, }); let aci = color.approximate_index(); let code = match field.as_str() { "dim_ext_line_color" => crate::entities::dim_override::DIMCLRE, "dim_text_color" => crate::entities::dim_override::DIMCLRT, "dim_text_fill_color" => crate::entities::dim_override::DIMTFILLCLR, _ => crate::entities::dim_override::DIMCLRD, }; let targets: Vec = handles .iter() .copied() .filter(|&h| { matches!( self.tabs[i].scene.document.get_entity(h), Some(codec::EntityType::Leader(_)) | Some(codec::EntityType::Dimension(_)) ) }) .collect(); if !targets.is_empty() { self.apply_property_op(i, "CHPROP", &targets, |app, handle| { if field == "dim_text_fill_color" { crate::entities::dim_override::set( &mut app.tabs[i].scene.document, handle, crate::entities::dim_override::DIMTFILL, Some(codec::xdata::XDataValue::Integer16( fill_mode.unwrap_or(2), )), ); if fill_mode != Some(2) { return; } } crate::entities::dim_override::set( &mut app.tabs[i].scene.document, handle, code, Some(codec::xdata::XDataValue::Integer16(aci)), ); }); self.tabs[i].properties.open_color_field = None; } return Task::none(); } if matches!( field.as_str(), "line_color" | "text_color" | "block_content_color" | "background_fill_color" ) { self.apply_property_op(i, "CHPROP", &handles, |app, handle| { if let Some(codec::EntityType::MultiLeader(leader)) = app.tabs[i].scene.document.get_entity_mut(handle) { match field.as_str() { "line_color" => { leader.line_color = color; leader.property_override_flags.insert( codec::entities::MultiLeaderPropertyOverrideFlags::LINE_COLOR, ); for root in &mut leader.context.leader_roots { for line in &mut root.lines { line.line_color = color; line.override_flags.insert( codec::entities::LeaderLinePropertyOverrideFlags::LINE_COLOR, ); } } } "text_color" => { leader.text_color = color; leader.context.text_color = color; leader.property_override_flags.insert( codec::entities::MultiLeaderPropertyOverrideFlags::TEXT_COLOR, ); } "block_content_color" => { leader.block_content_color = color; leader.context.block_content_color = color; leader.property_override_flags.insert( codec::entities::MultiLeaderPropertyOverrideFlags::BLOCK_CONTENT_COLOR, ); } "background_fill_color" => { leader.context.background_fill_color = color; } _ => {} } } }); self.tabs[i].properties.open_color_field = None; return Task::none(); } if matches!( field.as_str(), "tbl_cell_content_color" | "tbl_cell_background_color" ) { let cell_index = self.tabs[i].properties.prop_vertex; if !handles.is_empty() { self.apply_property_op(i, "TABLE CELL COLOR", &handles, |app, handle| { let Some(codec::EntityType::Table(table)) = app.tabs[i].scene.document.get_entity_mut(handle) else { return; }; let columns = table.column_count(); if columns == 0 { return; } if let Some(cell) = table.cell_mut( cell_index / columns, cell_index % columns, ) { use codec::entities::table::CellStateFlags; if cell.state.intersects( CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, ) { return; } let style = cell.style.get_or_insert_with(Default::default); if field == "tbl_cell_content_color" { style.content_color = color; style.property_flags.insert( codec::entities::table::CellStylePropertyFlags::CONTENT_COLOR, ); } else { style.background_color = color; style.fill_enabled = true; style.property_flags.insert( codec::entities::table::CellStylePropertyFlags::BACKGROUND_COLOR, ); } } table.block_record_handle = None; }); self.tabs[i].properties.open_color_field = None; } return Task::none(); } if !handles.is_empty() { let idx = if field == "gradient_color_2" { 1 } else { 0 }; self.apply_property_op(i, "CHPROP", &handles, |app, handle| { if let Some(codec::EntityType::Hatch(h)) = app.tabs[i].scene.document.get_entity_mut(handle) { while h.gradient_color.colors.len() <= idx { let value = if h.gradient_color.colors.is_empty() { 0.0 } else { 1.0 }; h.gradient_color.colors.push( codec::entities::hatch::GradientColorEntry { value, color: codec::types::Color::Index(7), }, ); } h.gradient_color.colors[idx].color = color.clone(); } }); // Rebuild hatch seeds so the gradient fill picks up the new // colour (synced_hatch_models only patches the main colour). self.tabs[i].scene.populate_hatches_from_document(); self.tabs[i].properties.open_color_field = None; } Task::none() } Message::PropColorPickerClose => { let i = self.active_tab; self.tabs[i].properties.color_picker_open = false; self.tabs[i].properties.hatch_pattern_picker_open = false; self.tabs[i].properties.hatch_pattern_search.clear(); Task::none() } Message::LayoutSwitch(name) => { self.layout_list_open = false; self.on_layout_switch(name) } Message::BlockEditSwitch(name) => { self.layout_list_open = false; self.on_block_edit_switch(name) } Message::LayoutReorder { from, to, after } => { let i = self.active_tab; if self.tabs[i].is_start { return Task::none(); } let mut paper: Vec = self.tabs[i] .scene .layout_names() .into_iter() .skip(1) .collect(); let Some(from_index) = paper.iter().position(|name| name == &from) else { return Task::none(); }; let Some(to_index) = paper.iter().position(|name| name == &to) else { return Task::none(); }; let Some(insertion) = reorder_insertion_index(from_index, to_index, after, paper.len()) else { return Task::none(); }; let moved = paper.remove(from_index); paper.insert(insertion, moved); self.push_undo_snapshot(i, "LAYOUT REORDER"); self.tabs[i].scene.set_layout_tab_order(&paper); self.tabs[i].dirty = true; Task::none() } Message::LayoutCreate => self.on_layout_create(), Message::LayoutDelete(name) => { let i = self.active_tab; let deleting_current = self.tabs[i].scene.current_layout == name; let cancel_task = if deleting_current { self.cancel_active_command_for_space_change() } else { Task::none() }; self.push_undo_snapshot(i, "LAYOUT DEL"); let switch_task = if deleting_current { self.on_layout_switch("Model".to_string()) } else { Task::none() }; if self.tabs[i].scene.delete_layout(&name) { self.layout_rename_state = None; self.command_line .push_output(crate::tf!("Layout \"{name}\" silindi").as_ref()); self.tabs[i].dirty = true; } Task::batch([cancel_task, switch_task]) } Message::LayoutRenameStart(name) => { if name != "Model" { self.layout_rename_state = Some((name.clone(), name)); // Focus the inline field so the user types into it // directly instead of the command line (issue #86). return iced::widget::operation::focus(iced::widget::Id::new( crate::ui::statusbar::LAYOUT_RENAME_INPUT_ID, )); } Task::none() } Message::LayoutRenameEdit(val) => { if let Some((orig, _)) = &self.layout_rename_state { let orig = orig.clone(); self.layout_rename_state = Some((orig, val)); } Task::none() } Message::LayoutRenameCommit => self.on_layout_rename_commit(), Message::LayoutRenameCancel => { self.layout_rename_state = None; Task::none() } // ── Layout Manager Panel ────────────────────────────────────────── Message::LayoutManagerOpen => { let i = self.active_tab; if self.tabs[i].is_start { self.command_line.push_info( crate::t!("Open or create a drawing to manage layouts.").as_ref(), ); return Task::none(); } let current = self.tabs[i].scene.current_layout.clone(); self.layout_manager_selected = current.clone(); self.layout_manager_rename_buf = if current == "Model" { String::new() } else { current }; self.active_modal = Some(super::ModalKind::LayoutManager); Task::none() } Message::LayoutManagerClose => { self.close_active_modal(); Task::none() } Message::LayoutManagerSelect(name) => { self.layout_manager_rename_buf = if name == "Model" { String::new() } else { name.clone() }; self.layout_manager_selected = name; Task::none() } Message::LayoutManagerRenameBuf(s) => { self.layout_manager_rename_buf = s; Task::none() } Message::LayoutManagerRenameCommit => { let i = self.active_tab; let old_name = self.layout_manager_selected.clone(); let new_name = self.layout_manager_rename_buf.trim().to_string(); if old_name == "Model" { self.command_line .push_error(crate::t!("Cannot rename the Model layout.").as_ref()); } else if new_name.is_empty() { self.command_line .push_error(crate::t!("Layout name cannot be empty.").as_ref()); } else if new_name == old_name { // no-op } else { self.push_undo_snapshot(i, "LAYOUT RENAME"); self.tabs[i].scene.rename_layout(&old_name, &new_name); if self.tabs[i].scene.current_layout == old_name { self.tabs[i].scene.set_current_layout(new_name.clone()); } self.layout_manager_selected = new_name.clone(); self.tabs[i].dirty = true; self.command_line.push_output( crate::tf!("Layout renamed: '{old_name}' → '{new_name}'").as_ref(), ); } Task::none() } Message::LayoutManagerNew => { let i = self.active_tab; if self.tabs[i].is_start { self.command_line .push_info(crate::t!("Open or create a drawing to add a layout.").as_ref()); return Task::none(); } let existing = self.tabs[i].scene.layout_names(); let n = (1usize..) .find(|n| !existing.contains(&format!("Layout{n}"))) .unwrap_or(1); let name = format!("Layout{n}"); self.push_undo_snapshot(i, "LAYOUT NEW"); match self.tabs[i].scene.add_layout(&name) { Ok(_) => { self.tabs[i].dirty = true; self.layout_manager_selected = name.clone(); self.layout_manager_rename_buf = name.clone(); self.command_line .push_output(crate::tf!("Layout '{name}' created.").as_ref()); } Err(e) => self .command_line .push_error(crate::tf!("LAYOUT: {e}").as_ref()), } Task::none() } Message::LayoutManagerDelete => { let i = self.active_tab; let name = self.layout_manager_selected.clone(); if name == "Model" { self.command_line .push_error(crate::t!("Cannot delete the Model layout.").as_ref()); Task::none() } else { let deleting_current = self.tabs[i].scene.current_layout == name; let cancel_task = if deleting_current { self.cancel_active_command_for_space_change() } else { Task::none() }; self.push_undo_snapshot(i, "LAYOUT DELETE"); let switch_task = if deleting_current { self.on_layout_switch("Model".to_string()) } else { Task::none() }; self.tabs[i].scene.delete_layout(&name); self.tabs[i].dirty = true; self.layout_manager_selected = "Model".to_string(); self.layout_manager_rename_buf = String::new(); self.command_line .push_output(crate::tf!("Layout '{name}' deleted.").as_ref()); Task::batch([cancel_task, switch_task]) } } Message::LayoutManagerMoveLeft => { let i = self.active_tab; let name = self.layout_manager_selected.clone(); if name == "Model" { return Task::none(); } let names = self.tabs[i].scene.layout_names(); // Find position among paper layouts only. let paper: Vec<&str> = names.iter().skip(1).map(|s| s.as_str()).collect(); if let Some(pos) = paper.iter().position(|&n| n == name) { if pos > 0 { self.push_undo_snapshot(i, "LAYOUT REORDER"); self.tabs[i].scene.swap_layout_order(&name, paper[pos - 1]); self.tabs[i].dirty = true; } } Task::none() } Message::LayoutManagerMoveRight => { let i = self.active_tab; let name = self.layout_manager_selected.clone(); if name == "Model" { return Task::none(); } let names = self.tabs[i].scene.layout_names(); let paper: Vec<&str> = names.iter().skip(1).map(|s| s.as_str()).collect(); if let Some(pos) = paper.iter().position(|&n| n == name) { if pos + 1 < paper.len() { self.push_undo_snapshot(i, "LAYOUT REORDER"); self.tabs[i].scene.swap_layout_order(&name, paper[pos + 1]); self.tabs[i].dirty = true; } } Task::none() } Message::LayoutManagerSetCurrent => { let name = self.layout_manager_selected.clone(); let task = self.on_layout_switch(name.clone()); if self.tabs[self.active_tab].scene.current_layout == name { self.command_line .push_output(crate::tf!("Switched to layout '{name}'.").as_ref()); } task } Message::SetTheme(theme) => { if self.ui_theme.name == "Custom" { self.saved_custom_palette = Some(self.ui_theme.palette); } self.ui_theme.name = theme.to_string(); self.ui_theme.palette = crate::app::config::UiThemePalette::from_iced(theme.seed()); self.theme_color_inputs = self.ui_theme.palette.hex_values(); self.active_theme = theme; self.sync_model_space_theme(true); self.persist_settings_if_changed(); Task::none() } // ── Keyboard Shortcuts Panel ────────────────────────────────────── Message::ShortcutsPanelOpen => { let mut rows: Vec<(String, String)> = self .shortcut_bindings .iter() .map(|(key, command)| (key.clone(), command.clone())) .collect(); rows.sort_by(|a, b| a.0.cmp(&b.0)); self.shortcut_editor_rows = rows; self.shortcut_capture_row = None; self.shortcut_pending_add = false; self.shortcut_reset_confirm = false; self.shortcut_close_confirm = false; self.active_modal = Some(super::ModalKind::Shortcuts); Task::none() } Message::ShortcutsPanelClose => { if !self.shortcut_close_confirm && self.shortcut_editor_dirty() { self.shortcut_close_confirm = true; return Task::none(); } self.shortcut_close_confirm = false; self.close_active_modal(); Task::none() } Message::ShortcutEditorCloseDiscard => { self.shortcut_close_confirm = false; self.close_active_modal(); Task::none() } Message::ShortcutEditorCloseKeep => { self.shortcut_close_confirm = false; Task::none() } Message::ShortcutEditorInput { idx, field, value } => { use crate::ui::window::shortcuts::ShortcutField; if let Some(row) = self.shortcut_editor_rows.get_mut(idx) { match field { ShortcutField::Key => row.0 = value.to_uppercase(), ShortcutField::Command => row.1 = value.to_uppercase(), } } // Typing never finishes the addition — only the draft row's // check button does, so no half-visible "ghost" row appears. Task::none() } Message::ShortcutEditorAdd => { if self.shortcut_pending_add { // One draft at a time: re-arm its Key cell instead. self.shortcut_capture_row = Some(0); } else { // The draft row goes to the top of the list so it is // visible without scrolling, with its Key cell armed. self.shortcut_editor_rows .insert(0, (String::new(), String::new())); self.shortcut_pending_add = true; self.shortcut_capture_row = Some(0); } Task::none() } Message::ShortcutCaptureStart(idx) => { // Clicking the armed cell again cancels capture. self.shortcut_capture_row = match self.shortcut_capture_row { Some(armed) if armed == idx => None, _ => Some(idx), }; Task::none() } Message::ShortcutCaptureKey(raw) => { if let Some(idx) = self.shortcut_capture_row.take() { let key = crate::app::shortcuts::normalize_key(&raw); if !key.is_empty() && self.shortcut_editor_rows.get_mut(idx).is_some() { self.shortcut_editor_rows[idx].0 = key; } } Task::none() } Message::ShortcutCaptureClear => { self.shortcut_capture_row = None; Task::none() } Message::ShortcutCaptureCancel => { // Esc backs out of capture and abandons an unfinished draft. self.shortcut_capture_row = None; if self.shortcut_pending_add { self.shortcut_editor_rows.remove(0); self.shortcut_pending_add = false; } Task::none() } Message::ShortcutEditorRemove(idx) => { if idx < self.shortcut_editor_rows.len() { self.shortcut_editor_rows.remove(idx); } if idx == 0 { // Removing the draft row is the ✕ cancel path. self.shortcut_pending_add = false; } // Armed indices shift when a row above them disappears. self.shortcut_capture_row = match self.shortcut_capture_row { Some(armed) if armed == idx => None, Some(armed) if armed > idx => Some(armed - 1), other => other, }; Task::none() } Message::ShortcutEditorApply => { self.finish_shortcut_editor(); Task::none() } Message::ShortcutEditorApplyExit => { self.finish_shortcut_editor(); self.close_active_modal(); Task::none() } Message::ShortcutEditorDraftAccept => { // The check button: finish the addition (keep the row in the // working table) without applying it. Only complete drafts // can be accepted — the button is disabled otherwise. self.finish_pending_add(); Task::none() } Message::ShortcutEditorResetAsk => { self.shortcut_reset_confirm = true; Task::none() } Message::ShortcutEditorResetDeny => { self.shortcut_reset_confirm = false; Task::none() } Message::ShortcutEditorResetConfirm => { self.reset_shortcuts_to_defaults(); self.command_line.push_info( crate::tf!("{} shortcut(s) applied.", self.shortcut_bindings.len()).as_ref(), ); Task::none() } Message::ShortcutPressed(key) => self.run_shortcut(&key), // ── Command Alias Editor (ALIASEDIT) ────────────────────────────── Message::AliasEditorOpen => { // Seed the working buffer from the current table, sorted by alias // so the list is stable and diffable. let mut rows: Vec<(String, String)> = self .command_aliases .iter() .map(|(a, c)| (a.clone(), c.clone())) .collect(); rows.sort_by(|a, b| a.0.cmp(&b.0)); self.alias_editor_rows = rows; self.alias_import_report.clear(); self.alias_pending_add = false; self.alias_reset_confirm = false; self.alias_close_confirm = false; self.active_modal = Some(super::ModalKind::Aliases); Task::none() } Message::AliasEditorInput { idx, field, value } => { use crate::ui::window::alias_editor::AliasField; // Aliases and commands are stored uppercase; uppercasing as the // user types keeps display and the committed table consistent. let value = value.to_uppercase(); if let Some(rowdata) = self.alias_editor_rows.get_mut(idx) { match field { AliasField::Alias => rowdata.0 = value, AliasField::Command => rowdata.1 = value, } } // Typing never finishes the addition — only the draft row's // check button does, so no half-visible "ghost" row appears. Task::none() } Message::AliasEditorImport => Task::perform( async { let handle = rfd::AsyncFileDialog::new() .add_filter(crate::t!("Command aliases (*.pgp)").as_ref(), &["pgp"]) .pick_file() .await?; #[cfg(not(target_arch = "wasm32"))] { Some(std::fs::read(handle.path()).map_err(|e| e.to_string())) } #[cfg(target_arch = "wasm32")] { Some(Ok(handle.read().await)) } }, Message::AliasEditorImported, ), Message::AliasEditorImported(result) => { // A picker can finish after the modal was dismissed. if self.active_modal == Some(super::ModalKind::Aliases) { match result { Some(Ok(bytes)) => self.import_alias_pgp(&bytes), Some(Err(error)) => self.command_line.push_error(&format!("PGP: {error}")), None => {} } } Task::none() } Message::AliasEditorAdd => { if self.alias_pending_add { // One draft at a time: keep the existing top draft. } else { // The draft row goes to the top of the list so it is // visible without scrolling. self.alias_editor_rows .insert(0, (String::new(), String::new())); self.alias_pending_add = true; } Task::none() } Message::AliasEditorDraftAccept => { // The check button: finish the addition (keep the row in the // working table) without applying it. Only complete drafts // can be accepted — the button is disabled otherwise. self.finish_pending_alias_add(); Task::none() } Message::AliasEditorDraftCancel => { // Esc backs out and abandons an unfinished draft. if self.alias_pending_add && !self.alias_editor_rows.is_empty() { self.alias_editor_rows.remove(0); self.alias_pending_add = false; } Task::none() } Message::AliasEditorRemove(idx) => { if idx < self.alias_editor_rows.len() { self.alias_editor_rows.remove(idx); } if idx == 0 { // Removing the draft row is the ✕ cancel path. self.alias_pending_add = false; } Task::none() } Message::AliasEditorApply => { self.finish_alias_editor(); Task::none() } Message::AliasEditorApplyExit => { self.finish_alias_editor(); self.close_active_modal(); Task::none() } Message::AliasEditorResetAsk => { self.alias_reset_confirm = true; Task::none() } Message::AliasEditorResetDeny => { self.alias_reset_confirm = false; Task::none() } Message::AliasEditorResetConfirm => { self.reset_aliases_to_defaults(); self.command_line.push_info( crate::tf!("{} alias(es) applied.", self.command_aliases.len()).as_ref(), ); Task::none() } Message::AliasEditorCloseDiscard => { self.alias_close_confirm = false; self.close_active_modal(); Task::none() } Message::AliasEditorCloseKeep => { self.alias_close_confirm = false; Task::none() } // ── Named Parameters (PARAMETERS) ───────────────────────────────── Message::NamedParametersOpen => { let i = self.active_tab; self.named_parameter_editor_rows = self.tabs[i] .scene .named_parameters() .iter() .map(|p| crate::ui::window::named_parameters::ParamEditorRow { name: p.name.clone(), formula: p.source.clone(), }) .collect(); self.active_modal = Some(super::ModalKind::NamedParameters); Task::none() } Message::NamedParametersInput { idx, field, value } => { use crate::ui::window::named_parameters::ParamField; if let Some(row) = self.named_parameter_editor_rows.get_mut(idx) { match field { ParamField::Name => row.name = value, ParamField::Formula => row.formula = value, } } Task::none() } Message::NamedParametersAdd => { self.named_parameter_editor_rows .push(crate::ui::window::named_parameters::ParamEditorRow::default()); Task::none() } Message::NamedParametersRemove(idx) => { if idx < self.named_parameter_editor_rows.len() { self.named_parameter_editor_rows.remove(idx); } Task::none() } Message::NamedParametersApply => { self.apply_named_parameter_editor_rows(); Task::none() } // ── Parameters / Constraints sections embedded in the // Properties panel ────────────────────────────────────────────── Message::PropParamInput { index, field, value, } => { self.tabs[self.active_tab] .properties .edit_buf .insert(crate::ui::properties::FieldKey::Param(index, field), value); Task::none() } Message::PropParamCommit { index, field } => self.on_prop_param_commit(index, field), Message::PropParamDelete(index) => self.on_prop_param_delete(index), Message::PropParamAddNew => self.on_prop_param_add_new(), Message::PropConstraintDelete(id) => { self.delete_parametric_constraint(id); Task::none() } Message::PropConstraintLinkClick(handles) => { let i = self.active_tab; self.tabs[i].scene.deselect_all(); for h in handles { self.tabs[i].scene.select_entity(h, false); } self.refresh_properties(); Task::none() } // ── Options / About windows ─────────────────────────────────── Message::OptionsOpen => { self.options_open(); Task::none() } Message::OptionsApply => { self.options_apply(); Task::none() } Message::OptionsOk => { self.options_apply(); self.options_forget(); self.close_active_modal(); Task::none() } Message::OptionsClose => { if !self.options_close_confirm && self.options_dirty() { self.options_close_confirm = true; return Task::none(); } self.options_discard(); self.close_active_modal(); Task::none() } Message::OptionsCloseDiscard => { self.options_discard(); self.close_active_modal(); Task::none() } Message::OptionsCloseKeep => { self.options_close_confirm = false; Task::none() } Message::OptionsTabChanged(tab) => { self.options_tab = tab; Task::none() } #[cfg(not(target_arch = "wasm32"))] Message::GraphicsBackendChanged(choice) => { self.graphics_prefs.backend = choice; crate::gpu_backend::save_prefs(self.graphics_prefs); Task::none() } #[cfg(not(target_arch = "wasm32"))] Message::GraphicsCompatToggled(on) => { self.graphics_prefs.compat_renderer = on; crate::gpu_backend::save_prefs(self.graphics_prefs); Task::none() } #[cfg(not(target_arch = "wasm32"))] Message::GraphicsLegacyGlToggled(on) => { self.graphics_prefs.legacy_gl = on; crate::gpu_backend::save_prefs(self.graphics_prefs); Task::none() } Message::CursorSizeChanged(value) => { self.cursor_size = value.clamp(1, 100); self.persist_settings_if_changed(); Task::none() } Message::PickBoxChanged(value) => { self.pick_box = value.clamp(0, 50); self.persist_settings_if_changed(); Task::none() } Message::DoubleClickBlockRefeditChanged(enabled) => { self.double_click_block_refedit = enabled; self.persist_settings_if_changed(); Task::none() } Message::DoubleClickBlockAtteditChanged(enabled) => { self.double_click_block_attedit = enabled; self.persist_settings_if_changed(); Task::none() } Message::CursorTypeChanged(value) => { self.cursor_type = value; self.persist_settings_if_changed(); Task::none() } Message::LineweightDisplayScaleChanged(value) => { self.lineweight_display_scale = value.clamp(25, 200); let scale = self.lineweight_display_scale as f32 / 100.0; for tab in &mut self.tabs { tab.scene.model_lineweight_scale = scale; } self.persist_settings_if_changed(); Task::none() } Message::CrosshairColorChanged(value) => { self.crosshair_color_input = value.clone(); if value.trim().is_empty() { self.crosshair_color = None; self.persist_settings_if_changed(); } else if let Some(rgb) = crate::app::config::parse_hex(&value) { self.crosshair_color = Some(rgb); self.persist_settings_if_changed(); } Task::none() } Message::QnewTemplateChanged(path) => { self.qnew_template = path; self.persist_settings_if_changed(); Task::none() } Message::NewFromTemplate(Some(path)) => { let task = self.update(Message::TabNew); let idx = self.active_tab; match self.apply_template(&path.to_string_lossy()) { Ok(_) => { self.tabs[idx].dirty = false; self.refresh_layer_panel(); self.sync_ribbon_styles(); } Err(error) => self .command_line .push_error(error["error"].as_str().unwrap_or("template")), } task } Message::NewFromTemplate(None) => Task::none(), Message::QnewTemplateBrowse => Task::perform( async { crate::sys::file_dialog() .add_filter( crate::t!("Drawing Template (*.dwt)").as_ref(), &["dwt", "DWT", "dwg", "DWG", "dxf", "DXF"], ) .pick_file() .await .map(|handle| crate::sys::handle_path(&handle).display().to_string()) }, |path| match path { Some(path) => Message::QnewTemplateChanged(path), None => Message::Noop, }, ), Message::CommandTextColorChanged(value) => { self.command_text_color_input = value.clone(); let color = if value.trim().is_empty() { None } else if let Some(rgb) = crate::app::config::parse_hex(&value) { Some(rgb) } else { return Task::none(); }; self.command_text_color = color; crate::ui::command_line::set_text_color(color); self.persist_settings_if_changed(); Task::none() } Message::SnapMarkerColorChanged(value) => { self.snap_marker_color_input = value.clone(); if value.trim().is_empty() { self.snap_marker_color = None; self.persist_settings_if_changed(); } else if let Some(rgb) = crate::app::config::parse_hex(&value) { self.snap_marker_color = Some(rgb); self.persist_settings_if_changed(); } Task::none() } Message::DefaultSaveFormatChanged(format) => { self.default_save_format = crate::io::canonical_save_format(&format).to_string(); self.persist_settings_if_changed(); Task::none() } Message::OptionsThemeChanged(name) => { if self.ui_theme.name == "Custom" && name != "Custom" { self.saved_custom_palette = Some(self.ui_theme.palette); } self.ui_theme.name = name; if let Some(theme) = crate::app::config::builtin_theme(&self.ui_theme.name) { self.ui_theme.palette = crate::app::config::UiThemePalette::from_iced(theme.seed()); self.theme_color_inputs = self.ui_theme.palette.hex_values(); self.active_theme = theme; } else { self.ui_theme.name = "Custom".to_string(); if let Some(saved) = self.saved_custom_palette { self.ui_theme.palette = saved; self.theme_color_inputs = saved.hex_values(); } self.active_theme = self.ui_theme.to_iced(); } self.sync_model_space_theme(true); self.persist_settings_if_changed(); Task::none() } Message::OptionsThemeColorChanged(index, value) => { if index >= self.theme_color_inputs.len() { return Task::none(); } self.theme_color_inputs[index] = value.clone(); if self.ui_theme.palette.set_hex(index, &value) { self.ui_theme.name = "Custom".to_string(); self.active_theme = self.ui_theme.to_iced(); self.sync_model_space_theme(true); self.persist_settings_if_changed(); } Task::none() } Message::ModelSpaceModeChanged(mode) => { self.model_space.mode = mode; self.sync_model_space_theme(true); self.persist_settings_if_changed(); Task::none() } Message::ModelSpaceBgChanged(hex) => { self.model_bg_input = hex.clone(); if hex.trim().is_empty() { self.model_space.custom_bg = None; self.model_space.mode = crate::app::config::ModelSpaceMode::MatchTheme; self.sync_model_space_theme(true); self.persist_settings_if_changed(); } else if let Some(rgb) = crate::app::config::parse_hex(&hex) { self.model_space.custom_bg = Some(rgb); self.model_space.mode = crate::app::config::ModelSpaceMode::Custom; self.sync_model_space_theme(true); self.persist_settings_if_changed(); } Task::none() } Message::PaperSpaceBgChanged(hex) => { self.paper_bg_input = hex.clone(); if hex.trim().is_empty() { self.model_space.custom_paper_bg = None; self.sync_model_space_theme(true); self.persist_settings_if_changed(); } else if let Some(rgb) = crate::app::config::parse_hex(&hex) { self.model_space.custom_paper_bg = Some(rgb); self.sync_model_space_theme(true); self.persist_settings_if_changed(); } Task::none() } Message::DeskSpaceBgChanged(hex) => { self.desk_bg_input = hex.clone(); if hex.trim().is_empty() { self.model_space.custom_desk_bg = None; self.sync_model_space_theme(false); self.persist_settings_if_changed(); } else if let Some(rgb) = crate::app::config::parse_hex(&hex) { self.model_space.custom_desk_bg = Some(rgb); self.sync_model_space_theme(false); self.persist_settings_if_changed(); } Task::none() } Message::GridOpacityChanged(opacity) => { self.model_space.grid_opacity = opacity.clamp(5, 100); self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() } Message::SelectionAreaToggled(enabled) => { self.model_space.selection_area = enabled; self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() } Message::SelectionOpacityChanged(opacity) => { self.model_space.selection_opacity = opacity.clamp(0, 100); self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() } Message::SelectionWindowColorChanged(color) => { self.model_space.selection_window_color = color; self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() } Message::SelectionCrossingColorChanged(color) => { self.model_space.selection_crossing_color = color; self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() } Message::SelectionHighlightColorChanged(color) => { self.model_space.selection_highlight_color = color; self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() } Message::GripSizeChanged(size) => { self.model_space.grip_size = size.clamp(1, 25); self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() } Message::SnapMarkerSizeChanged(size) => { self.model_space.snap_marker_size = size.clamp(1, 20); self.persist_settings_if_changed(); Task::none() } Message::GripColorChanged(color) => { self.model_space.grip_color = color; self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() } Message::GripHotChanged(color) => { self.model_space.grip_hot = color; self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() } Message::GripHoverChanged(color) => { self.model_space.grip_hover = color; self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() } Message::RestoreModelSpaceDisplayDefaults => { self.model_space.mode = crate::app::config::ModelSpaceMode::MatchTheme; self.model_space.custom_bg = None; self.model_space.custom_paper_bg = None; self.model_space.custom_desk_bg = None; self.model_space.grid_opacity = 18; self.model_bg_input.clear(); self.paper_bg_input.clear(); self.desk_bg_input.clear(); self.sync_model_space_theme(true); self.persist_settings_if_changed(); Task::none() } Message::GripObjectLimitChanged(limit) => { self.grip_object_limit = limit.clamp(0, 32767); self.persist_settings_if_changed(); Task::none() } Message::SelectionEffectToggled(enabled) => { self.model_space.selection_effect = enabled; self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() } // SELECTIONPREVIEW is a bitmask and the two checkboxes own one bit // each: 1 = rollover while idle, 2 = rollover during a command. Message::SelectionPreviewIdleToggled(enabled) => { self.model_space.selection_preview = set_preview_bit(self.model_space.selection_preview, 1, enabled); self.persist_settings_if_changed(); Task::none() } Message::SelectionPreviewCommandToggled(enabled) => { self.model_space.selection_preview = set_preview_bit(self.model_space.selection_preview, 2, enabled); self.persist_settings_if_changed(); Task::none() } // "Use Shift to add" is the inverse of PICKADD. Message::ShiftToAddToggled(shift_to_add) => { self.pick_add = !shift_to_add; self.persist_settings_if_changed(); Task::none() } Message::SaveTimeChanged(minutes) => { self.savetime_min = minutes.max(0); self.persist_settings_if_changed(); Task::none() } Message::PageSetupImportFile(path) => self.on_page_setup_import_file(path), Message::PageSetupOnNewLayoutChanged(enabled) => { self.plot_dialog.page_setup_on_new_layout = enabled; self.persist_settings_if_changed(); Task::none() } Message::BackupOnSaveChanged(enabled) => { self.backup_on_save = enabled; self.persist_settings_if_changed(); Task::none() } Message::TextFillChanged(filled) => { crate::scene::text::sdf_atlas::set_textfill(filled); self.invalidate_text_everywhere(); self.persist_settings_if_changed(); Task::none() } Message::ClipromptLinesChanged(lines) => { let lines = crate::app::settings::clamp_clipromptlines(lines); self.cliprompt_lines = lines; self.command_line .set_cliprompt_lines(lines.clamp(0, 50) as u8); self.persist_settings_if_changed(); Task::none() } Message::CommandLineOpacityChanged(percent) => { self.command_line.set_opacity_percent(percent); self.persist_settings_if_changed(); Task::none() } Message::CommandLineFadeChanged(ms) => { let ms = crate::app::settings::clamp_commandline_fade_ms(ms); self.commandline_fade_ms = ms; self.command_line.set_commandline_fade_ms(ms.max(0) as u32); self.persist_settings_if_changed(); Task::none() } Message::ZoomWheelReversedChanged(reversed) => { self.zoom_wheel_reversed = reversed; self.persist_settings_if_changed(); Task::none() } Message::MenuBarChanged(shown) => { self.show_menu_bar = shown; self.persist_settings_if_changed(); Task::none() } Message::RibbonVisibleChanged(shown) => { self.ribbon_closed = !shown; self.persist_settings_if_changed(); Task::none() } Message::HatchAssociativeChanged(associative) => { self.hatch_associative = associative; self.persist_settings_if_changed(); 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 // two never disagree about what the wheel is set to. self.zoom_factor_input = self.zoom_factor.to_string(); self.persist_settings_if_changed(); Task::none() } // Typed into freely; only committed when it parses, so clearing // the field to retype does not drop the wheel to its minimum. // This is the surface that reaches past the range the system // variable has. Message::ZoomFactorInputChanged(value) => { self.zoom_factor_input = value; if let Ok(factor) = self.zoom_factor_input.trim().parse::() { self.zoom_factor = crate::app::settings::clamp_zoom_factor(factor); self.persist_settings_if_changed(); } Task::none() } Message::RightClickModeChanged(mode) => { self.right_click_mode = mode; self.persist_settings_if_changed(); Task::none() } Message::RightClickHoldMsChanged(ms) => { self.right_click_hold_ms = super::settings::clamp_right_click_hold_ms(ms); self.persist_settings_if_changed(); Task::none() } Message::TextEditModeChanged(single) => { self.texteditmode = single; self.persist_settings_if_changed(); Task::none() } Message::DimContinueModeChanged(inherit) => { self.dimension_continue_mode = i16::from(inherit); self.persist_settings_if_changed(); Task::none() } Message::QdimSnapPriorityChanged(priority) => { self.quick_dimension_snap_priority = priority.min(1); self.persist_settings_if_changed(); Task::none() } // The sign carries on/off and the magnitude the mode, so switching // off and back on has to return to the mode that was chosen — the // same convention the status-bar pill uses when it negates. Message::AnnoAutoScaleChanged(mode) => { self.annotation_auto_scale = if mode == 0 { -self.annotation_auto_scale.abs().max(1) } else { mode.clamp(1, 4) }; self.persist_settings_if_changed(); Task::none() } // Typed into freely; only committed when it parses, so clearing // the field to retype does not snap the crosshair back to zero. Message::SnapAngleInputChanged(value) => { self.snap_angle_input = value; if let Ok(angle) = self.snap_angle_input.trim().parse::() { if angle.is_finite() { self.snap_angle_deg = angle.rem_euclid(360.0); self.persist_settings_if_changed(); } } Task::none() } Message::PolarIncrementChanged(deg) => { if deg.is_finite() && deg > 0.0 { self.polar_increment_deg = deg; self.persist_settings_if_changed(); } Task::none() } Message::ShowViewCubeChanged(show) => { self.show_viewcube = show; self.persist_settings_if_changed(); Task::none() } Message::ShowUcsIconChanged(show) => { self.show_ucs_icon = show; self.persist_settings_if_changed(); Task::none() } Message::UcsIconAtOriginChanged(at_origin) => { self.ucs_icon_at_origin = at_origin; self.persist_settings_if_changed(); Task::none() } // ISOLINES is baked into solid meshes, so rebuild once on release. Message::IsolinesChanged(value) => { let value = value.max(0); let changed = self .tabs .get(self.active_tab) .is_some_and(|tab| tab.scene.document.header.isolines != value); if changed { self.set_drawing_var(|header| header.isolines = value); self.isolines_awaiting_regen = true; } Task::none() } Message::IsolinesReleased => { if std::mem::take(&mut self.isolines_awaiting_regen) { self.regenerate_meshes(); } Task::none() } Message::DispSilhChanged(on) => { self.set_drawing_var(|header| header.display_silhouette = on); Task::none() } Message::SurfaceUChanged(value) => { self.set_drawing_var(|header| header.surface_u_density = value.clamp(0, 200)); Task::none() } Message::SurfaceVChanged(value) => { self.set_drawing_var(|header| header.surface_v_density = value.clamp(0, 200)); Task::none() } Message::SurfaceTypeChanged(value) => { self.set_drawing_var(|header| header.surface_type = value); Task::none() } Message::SolidHistChanged(record) => { self.set_drawing_var(|header| header.record_solid_history = record); Task::none() } Message::ShowHistChanged(mode) => { self.set_drawing_var(|header| header.show_solid_history = mode.clamp(0, 2)); let i = self.active_tab; if let Some(tab) = self.tabs.get_mut(i) { tab.scene.bump_geometry(); } Task::none() } Message::SelectionCyclingChanged(on) => { self.selection_cycling = on; self.persist_settings_if_changed(); Task::none() } Message::OpenFolder(path) => { // The fonts folder exists only once something is put in it. let _ = std::fs::create_dir_all(&path); crate::sys::open_url(&path, None) } #[cfg(target_arch = "wasm32")] Message::ChooseFolder(_) | Message::FolderChosen(..) => Task::none(), #[cfg(not(target_arch = "wasm32"))] Message::ChooseFolder(folder) => Task::perform( async { rfd::AsyncFileDialog::new() .pick_folder() .await .map(|handle| crate::sys::handle_path(&handle)) }, move |picked| match picked { Some(path) => Message::FolderChosen(folder, Some(path)), None => Message::Noop, }, ), #[cfg(not(target_arch = "wasm32"))] Message::FolderChosen(folder, path) => { use crate::ui::window::options::MovableFolder; match folder { MovableFolder::Fonts => crate::io::font_repo::set_fonts_folder(path), MovableFolder::PlotStyles => { crate::io::plot_style::set_plot_styles_folder(path) } } self.save_config(); Task::none() } Message::PickDragRectToggled(rectangle) => { self.pick_drag_rect = rectangle; self.persist_settings_if_changed(); Task::none() } Message::RestoreSelectionVisualDefaults => { self.model_space.selection_area = true; self.model_space.selection_opacity = 12; self.model_space.selection_window_color = 0; self.model_space.selection_crossing_color = 0; self.model_space.selection_highlight_color = 0; self.model_space.selection_effect = true; self.model_space.selection_preview = 3; self.model_space.grip_size = 5; self.model_space.grip_color = 0; self.model_space.grip_hot = 0; self.model_space.grip_hover = 0; // The button restores the *visual* defaults, which is why the // grip limit is here and PICKADD / PICKDRAG are not — those are // how selection behaves, not how it looks. self.grip_object_limit = crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT; self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() } Message::FileAssocChanged(enabled) => { // The same two calls FILEASSOC makes, so the checkbox and the // command cannot leave the setting and the registration // disagreeing. self.file_assoc_enabled = enabled; self.persist_settings_if_changed(); let outcome = if enabled { crate::io::file_association::register_as_handler() } else { crate::io::file_association::unregister_handler() }; match outcome { Ok(()) => { let said = if enabled { crate::t!("Registered as a .dwg/.dxf handler.") } else { crate::t!("No longer registered as a file handler.") }; self.command_line.push_output(said.as_ref()); } Err(why) => { // The setting is what the user asked for; the // registration is what the system allowed. Put the // checkbox back rather than showing a state that is not // true. self.file_assoc_enabled = !enabled; self.persist_settings_if_changed(); self.command_line .push_error(crate::tf!("File association failed: {why}").as_ref()); } } Task::none() } Message::ShowConstraintValuesChanged(enabled) => { self.show_constraint_values = enabled; self.persist_settings_if_changed(); Task::none() } Message::LanguageChanged(language) => { if self.language == language { return Task::none(); } match crate::i18n::set_language(language) { Ok(()) => { self.language = language; self.persist_settings_if_changed(); #[cfg(target_arch = "wasm32")] { let script = crate::scene::text::web_font::preload_language( &crate::i18n::active_language_tag(), ); return Task::batch([ Task::done(Message::PollWebFonts), Task::done(Message::ApplyWebFont(script)), ]); } } Err(error) => self .command_line .push_error(crate::tf!("Unable to change UI language: {error}").as_ref()), } Task::none() } Message::AboutOpen => { self.active_modal = Some(super::ModalKind::About); Task::none() } Message::GpuWarningOpen => { self.active_modal = Some(super::ModalKind::GpuWarning); Task::none() } Message::GpuWarningSilence => { self.gpu_warning_silenced = self.gpu_status.identity().unwrap_or_default(); self.close_active_modal(); Task::none() } 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, )); } if self.active_modal == Some(super::ModalKind::SheetSet) { self.close_sheet_set_dialog(); return Task::none(); } // The Background dialog returns to -VIEW's settings prompt. if self.active_modal == Some(super::ModalKind::ViewBackground) { self.view_background = None; self.close_active_modal(); let i = self.active_tab; return self.run_dash_view("SETTINGS", i); } if self.active_modal == Some(super::ModalKind::ImageAdjust) { self.image_adjust = None; } if self.active_modal == Some(super::ModalKind::Field) { self.close_field_dialog(); return Task::none(); } if matches!( self.active_modal, Some(super::ModalKind::AttDef | super::ModalKind::AttDefEdit) ) { 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() }; } if self.active_modal == Some(super::ModalKind::RefEdit) { self.refedit_dialog = None; } // Cancelling the section settings drops the waiting extraction. if self.active_modal == Some(super::ModalKind::PcSection) { crate::modules::insert::pc_extract::drop_job(); self.pc_section = None; } if self.active_modal == Some(super::ModalKind::RecoveryPrompt) { return self.update(Message::RecoveryDecline); } // The title-bar X, Escape, and MCP `close_modal` action mean // the same thing as Skip for this prompt. Record that choice // so reopening the drawing does not immediately nag again. if self.active_modal == Some(super::ModalKind::MissingFonts) { return self.update(Message::MissingFontsDismiss); } // The Options window's × and Esc behave like its Close button. if self.active_modal == Some(super::ModalKind::Options) { return self.update(Message::OptionsClose); } // Closing the crash-recovery prompt (✕ or Escape) DEFERS: // one stray keystroke must not delete the only recovery // copy, so the snapshot survives and is re-offered on the // next boot. Only the Discard button deletes. #[cfg(target_arch = "wasm32")] if self.active_modal == Some(super::ModalKind::AutosaveRestore) { return self.update(Message::WebAutosaveDefer); } // Closing the shortcut editor with un-applied rows needs an // explicit discard; a second close attempt (or the overlay's // Discard button) goes through. if self.active_modal == Some(super::ModalKind::Shortcuts) && !self.shortcut_close_confirm && self.shortcut_editor_dirty() { self.shortcut_close_confirm = true; return Task::none(); } self.shortcut_close_confirm = false; if self.active_modal == Some(super::ModalKind::Aliases) && !self.alias_close_confirm && self.alias_editor_dirty() { self.alias_close_confirm = true; return Task::none(); } self.alias_close_confirm = false; if self.active_modal == Some(super::ModalKind::DraftingSettings) && !self.drafting_settings_close_confirm && self.drafting_settings_dirty() { self.drafting_settings_close_confirm = true; return Task::none(); } self.drafting_settings_close_confirm = false; if self.active_modal == Some(super::ModalKind::AutoConstrainSettings) { if let Some(saved) = self.auto_constrain_saved.take() { self.auto_constrain_settings = saved; } } let resume_open_queue = self.active_modal == Some(super::ModalKind::Recovery); self.close_active_modal(); if resume_open_queue { self.drain_pending_open() } else { Task::none() } } Message::RecoveryClose => { self.close_active_modal(); self.drain_pending_open() } Message::RecoveryAttempt => { let open_id = self.next_open_id(); let Some(opening) = self.opening.as_mut() else { self.close_active_modal(); return Task::none(); }; let model_bg = self .default_bg_color .unwrap_or_else(|| self.model_space.resolve_model_bg(&self.active_theme)); #[cfg(not(target_arch = "wasm32"))] if let Some(path) = opening.source_path.clone() { let current_fingerprint = crate::io::edit_lock::FileFingerprint::capture(&path).ok(); if current_fingerprint.as_ref() != opening.fingerprint.as_ref() { let progress = std::sync::Arc::new(crate::io::OpenProgressState::new( super::OPEN_PHASE_READING, )); opening.id = open_id; opening.state = progress.clone(); opening.started = Instant::now(); opening.recovery_error = None; opening.recovery_read_stats = None; opening.fingerprint = current_fingerprint; opening.size_bytes = std::fs::metadata(&path) .map(|metadata| metadata.len()) .unwrap_or(0); self.close_active_modal(); return Task::perform( crate::io::open_path_with_phase(path, progress, model_bg), move |result| Message::FileOpened(open_id, result), ); } } let Some(initial_error) = opening.recovery_error.take() else { self.close_active_modal(); return Task::none(); }; let initial_stats = opening.recovery_read_stats.take(); let Some(path) = opening.source_path.clone() else { self.close_active_modal(); return Task::none(); }; let progress = std::sync::Arc::new(crate::io::OpenProgressState::new( super::OPEN_PHASE_READING, )); opening.id = open_id; opening.state = progress.clone(); opening.started = Instant::now(); #[cfg(target_arch = "wasm32")] let recovery_bytes = opening.recovery_bytes.take(); self.close_active_modal(); #[cfg(not(target_arch = "wasm32"))] { Task::perform( crate::io::recover_path_with_phase( path, progress, model_bg, initial_error, initial_stats, ), move |result| Message::FileOpened(open_id, result), ) } #[cfg(target_arch = "wasm32")] { let _ = model_bg; let Some(bytes) = recovery_bytes else { self.opening = None; return self.drain_pending_open(); }; Task::perform( crate::io::recover_web_bytes( path.to_string_lossy().into_owned(), bytes, progress, initial_error, initial_stats, ), move |outcome| Message::WebFileOpened(open_id, outcome), ) } } Message::RecoveryDecline => { let declined = self.opening.take(); self.close_active_modal(); if let Some(opening) = declined { self.command_line .push_info(crate::tf!("Recovery cancelled: \"{}\"", opening.name).as_ref()); } self.drain_pending_open() } Message::RecoverySaveAs => { if !self.pending_opens.is_empty() { self.close_active_modal(); return self.drain_pending_open(); } let Some(tab_id) = self .recovery_report .as_ref() .and_then(|report| report.tab_id) else { self.close_active_modal(); return Task::none(); }; let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else { self.close_active_modal(); return Task::none(); }; self.close_active_modal(); self.active_tab = i; self.open_save_dialog_window(i) } Message::RecoveryShowLog => { let Some(report) = self.recovery_report.as_ref() else { return Task::none(); }; #[cfg(not(target_arch = "wasm32"))] { if let Some(path) = &report.log_path { if let Err(error) = crate::sys::reveal_in_file_manager(path) { self.command_line.push_error( crate::tf!("Could not show recovery log: {error}").as_ref(), ); } } } #[cfg(target_arch = "wasm32")] { let name = report.suggested_download_name(); let body = report.log_text(); crate::sys::download_bytes(&name, body.as_bytes()); } Task::none() } Message::AttrEditorOpen(handle) => { self.open_attribute_editor(handle); Task::none() } Message::AttrEditorTab(t) => { self.attr_editor_tab = t; Task::none() } Message::AttrEditorSelect(idx) => { if idx < self.attr_editor_rows.len() { self.attr_editor_selected = idx; } Task::none() } Message::AttrEditorInput { idx, value } => { if let Some(r) = self.attr_editor_rows.get_mut(idx) { r.value = value; } Task::none() } Message::AttrEditorTextStyle(s) => { if let Some(r) = self.attr_row_selected_mut() { r.text_style = s; } Task::none() } Message::AttrEditorJustify(label) => { if let Some((h, v)) = crate::ui::window::attribute_editor::justify_from_label(&label) { if let Some(r) = self.attr_row_selected_mut() { r.h_align = h; r.v_align = v; } } Task::none() } Message::AttrEditorHeight(s) => { if let Some(r) = self.attr_row_selected_mut() { r.height = s; } Task::none() } Message::AttrEditorRotation(s) => { if let Some(r) = self.attr_row_selected_mut() { r.rotation = s; } Task::none() } Message::AttrEditorWidth(s) => { if let Some(r) = self.attr_row_selected_mut() { r.width_factor = s; } Task::none() } Message::AttrEditorOblique(s) => { if let Some(r) = self.attr_row_selected_mut() { r.oblique = s; } Task::none() } Message::AttrEditorBackwards(b) => { if let Some(r) = self.attr_row_selected_mut() { r.backwards = b; } Task::none() } Message::AttrEditorUpsideDown(b) => { if let Some(r) = self.attr_row_selected_mut() { r.upside_down = b; } Task::none() } Message::AttrEditorLayer(s) => { if let Some(r) = self.attr_row_selected_mut() { r.layer = s; } Task::none() } Message::AttrEditorLinetype(s) => { if let Some(r) = self.attr_row_selected_mut() { r.linetype = if s == "ByLayer" { String::new() } else { s }; } Task::none() } Message::AttrEditorColor(label) => { if let Some(c) = crate::ui::window::attribute_editor::color_from_label(&label) { if let Some(r) = self.attr_row_selected_mut() { r.color = c; } } Task::none() } Message::AttrEditorLineweight(lw) => { if let Some(r) = self.attr_row_selected_mut() { r.line_weight = lw; } Task::none() } Message::AttrEditorApply => self.on_attr_editor_apply(), Message::ModalGrab => { // Start a drag; the first ModalDragMove seeds the reference. self.modal_dragging = true; self.modal_drag_last = None; Task::none() } Message::ModalResizeGrab => { // Start a resize; the first ModalDragMove seeds the reference. self.modal_resizing = true; self.modal_drag_last = None; Task::none() } Message::ModalContentResized(size) => { if !size.width.is_finite() || !size.height.is_finite() || size.width <= 0.0 || size.height <= 0.0 { return Task::none(); } let first_measurement = self.modal_content_size.replace(size).is_none(); self.mark_startup_modal_shown(); if first_measurement { let initial_width = self.mtext_editor.as_ref().and_then(|editor| { editor.editing.is_none().then(|| { (size.width - 2.0 * super::view::overlay::MTEXT_PREVIEW_PAD).max(80.0) / editor.preview_scale() }) }); if let (Some(editor), Some(width)) = (self.mtext_editor.as_mut(), initial_width) { editor.rect_width = f64::from(width.max(1e-6)); self.rebuild_mtext_preview(); } } Task::none() } Message::RibbonLayerFilterChanged(f) => { self.ribbon.layer_filter = f; Task::none() } Message::LayerManagerFilterChanged(f) => { let i = self.active_tab; self.tabs[i].layers.filter = f; Task::none() } Message::LayerNameColGrab => { // Start a Name-column divider drag; the dock's pointer capture // feeds it (see `on_dock`). self.dock_drag = Some(crate::ui::dock::DockDrag::LayerColumn); self.dock_drag_last = None; Task::none() } Message::ModalDragMove(p) => { if let Some(last) = self.modal_drag_last { let (dx, dy) = (p.x - last.x, p.y - last.y); if self.modal_resizing { // The grip sits bottom-right, so dragging out grows the // box. The delta is added to each dialog's natural size, // so clamp it at zero — dragging in past the natural size // does nothing (the natural size is the floor). let nx = (self.modal_resize.x + dx).max(0.0); let ny = (self.modal_resize.y + dy).max(0.0); let (rx, ry) = (nx - self.modal_resize.x, ny - self.modal_resize.y); self.modal_resize.x = nx; self.modal_resize.y = ny; // The box is centred, so shift the centre by half the // growth to pin the top-left corner — the grip then // tracks the cursor instead of drifting at half speed. self.modal_offset.x += rx * 0.5; self.modal_offset.y += ry * 0.5; } else if self.modal_dragging { self.modal_offset.x += dx; self.modal_offset.y += dy; // Clamp so the dialog stops at the window edge instead // of being squeezed (the off-centre padding shrinks the // dialog once it overlaps a border). if let Some((cw, ch)) = self.modal_outer_size() { let (ww, wh) = if self.mtext_editor.is_some() { self.vp_size } else { self.win_size }; let max_x = ((ww - cw) * 0.5).max(0.0); let max_y = ((wh - ch) * 0.5).max(0.0); self.modal_offset.x = self.modal_offset.x.clamp(-max_x, max_x); self.modal_offset.y = self.modal_offset.y.clamp(-max_y, max_y); } } } if self.modal_dragging || self.modal_resizing { self.modal_drag_last = Some(p); } Task::none() } Message::ModalDragRelease => { self.modal_dragging = false; self.modal_resizing = false; self.modal_drag_last = None; Task::none() } Message::AboutCopyInfo => { let info = crate::ui::window::about::report(); #[cfg(target_arch = "wasm32")] { crate::sys::write_clipboard_text(&info); Task::none() } #[cfg(not(target_arch = "wasm32"))] { iced::clipboard::write(info).discard() } } // ── Plugin Manager window ───────────────────────────────────── Message::PluginManagerOpen => { #[cfg(target_arch = "wasm32")] { self.active_modal = Some(super::ModalKind::PluginManager); return Task::none(); } #[cfg(not(target_arch = "wasm32"))] { // Refresh the on-disk external-plugin list each time the manager // opens so newly dropped-in packages show up. self.external_plugins = crate::plugin::external::discover(); self.marketplace_status.clear(); self.active_modal = Some(super::ModalKind::PluginManager); // Fetch the curated registry and release lists for linked repos. self.plugin_registry_loading = true; self.plugin_registry_error = None; self.plugin_registry_error_details_open = false; let mut tasks = vec![self.fetch_registry_task()]; let release_repos: rustc_hash::FxHashSet = self .plugin_repos .iter() .cloned() .chain( self.external_plugins .iter() .filter_map(|plugin| plugin.repository.clone()), ) .collect(); tasks.extend( release_repos .into_iter() .map(|r| self.fetch_releases_task(r)), ); if self.selected_plugin_repo.is_none() { self.selected_plugin_repo = self .external_plugins .iter() .find_map(|plugin| plugin.repository.clone()) .or_else(|| { self.plugin_registry.first().map(|entry| entry.repo.clone()) }) .or_else(|| self.plugin_repos.first().cloned()); } if let Some(repo) = self.selected_plugin_repo.clone() { if !self.plugin_readmes.contains_key(&repo) && self.plugin_readme_loading.insert(repo.clone()) { tasks.push(self.fetch_plugin_readme_task(repo)); } } return Task::batch(tasks); } } Message::PluginManagerClose => { self.close_active_modal(); Task::none() } Message::SetPluginEnabled(id, enabled) => { if enabled { self.disabled_plugins.remove(&id); } else { self.disabled_plugins.insert(id); } self.rebuild_ribbon_modules(); self.persist_settings_if_changed(); Task::none() } Message::PluginRepoInput(s) => { self.plugin_repo_input = s; Task::none() } Message::PluginSearchInput(s) => { self.plugin_search_input = s; Task::none() } Message::PluginRepoAdd => { let Some(repo) = crate::plugin::external::normalize_repository(&self.plugin_repo_input) else { self.marketplace_status = crate::t!("Enter a GitHub URL or repository in owner/repo format.") .into_owned(); return Task::none(); }; if self.plugin_repos.contains(&repo) || self.plugin_registry.iter().any(|entry| entry.repo == repo) { self.marketplace_status = crate::tf!("{repo} is already in the catalog.").into_owned(); self.selected_plugin_repo = Some(repo.clone()); if !self.plugin_readmes.contains_key(&repo) && self.plugin_readme_loading.insert(repo.clone()) { return self.fetch_plugin_readme_task(repo); } return Task::none(); } if self .external_plugins .iter() .any(|plugin| plugin.repository.as_deref() == Some(repo.as_str())) { self.marketplace_status = crate::tf!("{repo} is already installed.").into_owned(); self.selected_plugin_repo = Some(repo.clone()); if !self.plugin_readmes.contains_key(&repo) && self.plugin_readme_loading.insert(repo.clone()) { return self.fetch_plugin_readme_task(repo); } return Task::none(); } self.plugin_repos.push(repo.clone()); self.plugin_repo_input.clear(); self.persist_settings_if_changed(); self.marketplace_status = crate::tf!("Fetching releases for {repo}…").into_owned(); self.selected_plugin_repo = Some(repo.clone()); self.plugin_readmes.remove(&repo); self.plugin_readme_loading.insert(repo.clone()); Task::batch(vec![ self.fetch_releases_task(repo.clone()), self.fetch_plugin_readme_task(repo), ]) } Message::PluginRepoRemove(repo) => { self.plugin_repos.retain(|r| r != &repo); self.repo_release_tags.remove(&repo); self.repo_selected_tag.remove(&repo); if self.selected_plugin_repo.as_deref() == Some(repo.as_str()) && !self.plugin_registry.iter().any(|entry| entry.repo == repo) { self.selected_plugin_repo = self.plugin_registry.first().map(|entry| entry.repo.clone()); } self.persist_settings_if_changed(); Task::none() } Message::PluginRegistryFetched(Ok(entries)) => { self.plugin_registry_loading = false; self.plugin_registry_error = None; self.plugin_registry_error_details_open = false; // Fetch releases for every curated repo so the dropdowns fill in. #[cfg(not(target_arch = "wasm32"))] { if self.selected_plugin_repo.is_none() { self.selected_plugin_repo = self .external_plugins .iter() .find_map(|plugin| { plugin.repository.clone().or_else(|| { entries .iter() .find(|entry| entry.name.eq_ignore_ascii_case(&plugin.name)) .map(|entry| entry.repo.clone()) }) }) .or_else(|| entries.first().map(|entry| entry.repo.clone())); } let mut tasks: Vec<_> = entries .iter() .map(|e| self.fetch_releases_task(e.repo.clone())) .collect(); self.plugin_registry = entries; if let Some(repo) = self.selected_plugin_repo.clone() { if !self.plugin_readmes.contains_key(&repo) && self.plugin_readme_loading.insert(repo.clone()) { tasks.push(self.fetch_plugin_readme_task(repo)); } } return Task::batch(tasks); } #[cfg(target_arch = "wasm32")] { self.plugin_registry = entries; Task::none() } } Message::PluginRegistryFetched(Err(e)) => { self.plugin_registry_loading = false; self.plugin_registry_error = Some(e); self.plugin_registry_error_details_open = false; Task::none() } Message::PluginRegistryRetry => { #[cfg(not(target_arch = "wasm32"))] { self.plugin_registry_loading = true; self.plugin_registry_error = None; self.plugin_registry_error_details_open = false; return self.fetch_registry_task(); } #[cfg(target_arch = "wasm32")] Task::none() } Message::PluginRegistryErrorDetailsToggle => { if self.plugin_registry_error.is_some() { self.plugin_registry_error_details_open = !self.plugin_registry_error_details_open; } Task::none() } Message::PluginRegistryCopyDiagnostics => { #[cfg(not(target_arch = "wasm32"))] if let Some(error) = &self.plugin_registry_error { return iced::clipboard::write(format!( "Open CAD Studio v{}\nOS: {}\nArchitecture: {}\nRegistry: {}\nError: {}", env!("OCS_FULL_VERSION"), std::env::consts::OS, std::env::consts::ARCH, crate::plugin::marketplace::REGISTRY_URL, error, )) .discard(); } Task::none() } Message::PatronsFetched(Ok(names)) => { // Merge the hand-maintained supporters and rank everyone by // amount (also sorts the web list, which arrives unsorted). self.patrons = crate::patreon::merge_manual(names); Task::none() } // No token / offline: still show any hand-maintained supporters // (Start page shows a "Support on Patreon" prompt when empty). Message::PatronsFetched(Err(_)) => { self.patrons = crate::patreon::merge_manual(Vec::new()); Task::none() } Message::VideosFetched(Ok(videos)) => { self.videos_loading = false; self.set_videos(videos); Task::none() } // Offline / markup change: keep whatever the on-disk cache seeded. Message::VideosFetched(Err(_)) => { self.videos_loading = false; Task::none() } Message::DiscussionsFetched(Ok(discussions)) => { self.discussions_loading = false; self.discussions = discussions; Task::none() } // Offline: keep the native cache (web leaves the panel empty). Message::DiscussionsFetched(Err(_)) => { self.discussions_loading = false; Task::none() } Message::RecentThumbsLoaded(thumbs) => { for (path, handle) in thumbs { self.recent_thumbs.insert(path, handle); } Task::none() } Message::PluginReleasesFetched(repo, Ok(releases)) => { if let Some(first) = releases.first() { self.repo_selected_tag .entry(repo.clone()) .or_insert_with(|| first.tag.clone()); } if self.marketplace_status == crate::tf!("Fetching releases for {repo}…").into_owned() { self.marketplace_status = crate::tf!( "Repository added. {} installable release(s) found.", releases.len() ) .into_owned(); } self.repo_release_tags.insert(repo, releases); Task::none() } Message::PluginReleasesFetched(repo, Err(e)) => { self.marketplace_status = format!("{repo}: {e}"); Task::none() } Message::PluginReleaseSelect(repo, tag) => { self.repo_selected_tag.insert(repo, tag); Task::none() } Message::PluginReadmeSelect(repo) => { self.selected_plugin_repo = Some(repo.clone()); if self.plugin_readme_loading.contains(&repo) { return Task::none(); } if matches!(self.plugin_readmes.get(&repo), Some(Ok(_))) { return Task::none(); } // A second click on an error state acts as retry. self.plugin_readmes.remove(&repo); self.plugin_readme_loading.insert(repo.clone()); self.fetch_plugin_readme_task(repo) } Message::PluginReadmeFetched(repo, result) => { self.plugin_readme_loading.remove(&repo); self.plugin_readmes.insert( repo, result.map(|source| iced::widget::markdown::Content::parse(&source)), ); Task::none() } Message::PluginInstall(repo) => { let Some(tag) = self.repo_selected_tag.get(&repo).cloned() else { return Task::none(); }; self.marketplace_status = crate::tf!("Installing {repo} {tag}…").into_owned(); self.install_task(repo, tag) } Message::PluginUpdate(repo, tag) => { self.marketplace_status = crate::tf!("Updating {repo} to {tag}…").into_owned(); self.install_task(repo, tag) } Message::PluginInstalled(Ok(id)) => { self.marketplace_status = crate::tf!("Installed '{id}'. Restart to load it.").into_owned(); self.plugin_load_errors.remove(&id); #[cfg(not(target_arch = "wasm32"))] { self.external_plugins = crate::plugin::external::discover(); } Task::none() } Message::PluginInstalled(Err(e)) => { self.marketplace_status = crate::tf!("Install failed: {e}").into_owned(); Task::none() } Message::PluginUninstall(id) => { #[cfg(not(target_arch = "wasm32"))] { // Stop the plugin runner first so Windows releases the DLL // and allows the package directory to be deleted. if !crate::plugin::external::remove_plugin(&id) { self.marketplace_status = crate::tf!("Uninstall failed: plugin '{id}' did not stop in time") .into_owned(); return Task::none(); } match crate::plugin::external::uninstall(&id) { Ok(()) => { self.marketplace_status = crate::tf!("Uninstalled '{id}'.").into_owned(); self.plugin_load_errors.remove(&id); self.loaded_plugin_ids.remove(&id); self.rebuild_ribbon_modules(); self.external_plugins = crate::plugin::external::discover(); } Err(e) => { self.marketplace_status = crate::tf!("Uninstall failed: {e}").into_owned(); } } } #[cfg(target_arch = "wasm32")] let _ = id; Task::none() } Message::PointStyleSetMode(mode) => { self.set_point_mode_bits(!0, mode); Task::none() } Message::PointStyleSizeRelative(relative) => { self.point_size_relative = relative; self.apply_point_size(); Task::none() } Message::PointStyleSizeInput(s) => { self.point_size_buf = s; Task::none() } Message::PointStyleApplySize => { self.apply_point_size(); Task::none() } Message::PointStyleOk => { self.apply_point_size(); self.close_active_modal(); Task::none() } Message::EnterViewport(handle) => { let i = self.active_tab; let context_changed = self.tabs[i].scene.active_viewport != Some(handle); let cancel_task = if context_changed { self.cancel_active_command_for_space_change() } else { Task::none() }; let perf = crate::perf::enabled(); let total = Instant::now(); if context_changed { self.tabs[i].scene.clear_preview_wire(); } // Clear paper-space selection before entering model space. self.tabs[i].scene.deselect_all(); self.tabs[i].scene.active_viewport = Some(handle); // Fold a stale UTM saved view onto the effective (auto-fit) // centre so pan/zoom, paper↔model and the display all agree — // otherwise the camera auto-fits to the model while the cursor // math stays at the origin, jittering as pan toggles the two. let phase = Instant::now(); self.tabs[i].scene.normalize_active_viewport_view(); let normalize_ms = phase.elapsed().as_secs_f64() * 1000.0; // Grid/snap follow the entered viewport. let phase = Instant::now(); self.adopt_view_display(i); let display_ms = phase.elapsed().as_secs_f64() * 1000.0; // Adopt the entered viewport's own per-viewport UCS. let phase = Instant::now(); self.tabs[i].refresh_active_ucs(); let ucs_ms = phase.elapsed().as_secs_f64() * 1000.0; let phase = Instant::now(); self.refresh_properties(); let properties_ms = phase.elapsed().as_secs_f64() * 1000.0; self.command_line.push_output(crate::t!("MSPACE").as_ref()); if perf { crate::perf_record!( "[perf] viewport-enter total={:.2}ms normalize={:.2}ms display={:.2}ms ucs={:.2}ms properties={:.2}ms handle={}", total.elapsed().as_secs_f64() * 1000.0, normalize_ms, display_ms, ucs_ms, properties_ms, handle.value(), ); } if context_changed { self.sync_dyn_fields(); } cancel_task } Message::ExitViewport => { let i = self.active_tab; let context_changed = self.tabs[i].scene.active_viewport.is_some(); let cancel_task = if context_changed { self.cancel_active_command_for_space_change() } else { Task::none() }; if context_changed { self.tabs[i].scene.clear_preview_wire(); } // Clear model-space selection before returning to paper space. self.tabs[i].scene.deselect_all(); self.tabs[i].scene.active_viewport = None; // Grid/snap return to the paper sheet's own state. self.adopt_view_display(i); // Paper space has no UCS — drop the viewport's UCS. self.tabs[i].refresh_active_ucs(); self.refresh_properties(); self.command_line.push_output(crate::t!("PSPACE").as_ref()); if context_changed { self.sync_dyn_fields(); } cancel_task } Message::MspaceCommand => { let i = self.active_tab; if self.tabs[i].scene.current_layout == "Model" { self.command_line.push_error( crate::t!("MS is only available in paper space layouts.").as_ref(), ); return Task::none(); } if self.tabs[i].scene.active_viewport.is_some() { // Already in MSPACE — nothing to do. return Task::none(); } match self.tabs[i].scene.first_user_viewport() { Some(handle) => Task::done(Message::EnterViewport(handle)), None => { self.command_line .push_error(crate::t!("No viewport found in this layout.").as_ref()); Task::none() } } } Message::PspaceCommand => Task::done(Message::ExitViewport), Message::Undo => { // Mid-command Ctrl+Z: a drawing command steps itself back // (PLINE pops the last vertex) instead of the document undo // swallowing the whole in-progress object. let i = self.active_tab; let step = self.tabs[i] .active_cmd .as_mut() .and_then(|c| c.on_undo_step()); if let Some(r) = step { return self.apply_cmd_result(r); } self.undo_active_tab(); Task::none() } Message::Redo => { self.redo_active_tab(); Task::none() } Message::UndoMany(steps) => { self.ribbon.close_dropdown(); self.undo_steps(steps); Task::none() } Message::RedoMany(steps) => { self.ribbon.close_dropdown(); self.redo_steps(steps); Task::none() } Message::Noop => Task::none(), Message::StatusMenuTooltipHidden(hidden) => { self.status_menu_tooltip_hidden = hidden; if hidden { self.polar_custom_input.clear(); } Task::none() } // ── Unsaved-changes dialog ──────────────────────────────────── Message::UnsavedDialogCancel => { self.pending_close = None; self.pending_tab_closes.clear(); self.close_unsaved_dialog_window() } Message::UnsavedDialogDiscard => self.on_unsaved_dialog_discard(), Message::UnsavedDialogSave => self.on_unsaved_dialog_save(), Message::BlockEditChangesSave => self.resolve_block_edit_close(true), Message::BlockSaveAsName(name) => { let i = self.active_tab; let error = self.validate_block_save_as_name(i, &name).err(); if let Some(state) = self.block_save_as.as_mut() { state.name = name; state.error = error; } Task::none() } Message::BlockSaveAsApply => self.apply_block_save_as_dialog(), Message::BlockEditPickerName(name) => { if let Some(state) = self.block_edit_picker.as_mut() { state.name = name.clone(); let trimmed = name.trim(); state.error = if trimmed.is_empty() { None } else { crate::scene::block_service::validate_block_name(trimmed) .err() .map(|e| e.to_string()) }; if let Some(tab_idx) = self.tabs.iter().position(|t| t.id == state.document_id) { if let Some(matched) = state .existing_names .iter() .find(|n| n.eq_ignore_ascii_case(trimmed)) { state.preview = self.tabs[tab_idx].scene.block_preview_wires(matched); state.description = self.tabs[tab_idx] .scene .document .block_records .get(matched) .map(|b| b.description.clone()) .unwrap_or_default(); } else { state.preview.clear(); state.description.clear(); } } } Task::none() } Message::BlockEditPickerSelect(name) => { if let Some(state) = self.block_edit_picker.as_mut() { state.name = name.clone(); state.error = None; if let Some(tab_idx) = self.tabs.iter().position(|t| t.id == state.document_id) { state.preview = self.tabs[tab_idx].scene.block_preview_wires(&name); state.description = self.tabs[tab_idx] .scene .document .block_records .get(&name) .map(|b| b.description.clone()) .unwrap_or_default(); } } Task::none() } Message::BlockEditPickerApply => self.apply_bedit_dialog(), Message::BlockEditChangesDiscard => self.resolve_block_edit_close(false), Message::BlockEditChangesCancel => { self.cancel_block_edit_close(); Task::none() } Message::AecDropSameVersion => self.on_aec_drop_same_version(), Message::AecDropProceed => self.on_aec_drop_proceed(), Message::AecDropBack => { self.active_modal = Some(crate::app::ModalKind::SaveDialog); Task::none() } Message::AutoSave => self.on_autosave(), Message::ThumbnailCaptureFrame => self.on_thumbnail_capture_frame(), Message::ThumbnailCaptureFinished => { self.thumbnail_capture_clean = false; Task::none() } #[cfg(not(target_arch = "wasm32"))] Message::SaveFinished(outcome) => self.on_save_finished(outcome), #[cfg(target_arch = "wasm32")] Message::WebSaveScreenshot { tab_id, filename, ext, version, bounds, screenshot, } => self.on_web_save_screenshot(tab_id, filename, ext, version, bounds, screenshot), #[cfg(target_arch = "wasm32")] Message::WebSaveSerialized { tab_id, filename, autosave_key, version, geometry_epoch, edit_revision, camera_generation, result, } => self.on_web_save_serialized( tab_id, filename, autosave_key, version, geometry_epoch, edit_revision, camera_generation, result, ), #[cfg(target_arch = "wasm32")] Message::WebSaveHandlePicked { tab_id, filename, ext, version, picked, } => self.on_web_save_handle_picked(tab_id, filename, ext, version, picked), #[cfg(target_arch = "wasm32")] Message::WebSaveWritten { tab_id, filename, autosave_key, version, geometry_epoch, edit_revision, camera_generation, result, bytes, } => self.on_web_save_written( tab_id, filename, autosave_key, version, geometry_epoch, edit_revision, camera_generation, result, bytes, ), #[cfg(target_arch = "wasm32")] Message::WebSaveallSerialized { skipped, results } => { self.on_web_saveall_serialized(skipped, results) } #[cfg(target_arch = "wasm32")] Message::WebArchiveSerialized { cmd, zip_name, filename, deps, skipped, result, } => self.on_web_archive_serialized(cmd, zip_name, filename, deps, skipped, result), #[cfg(not(target_arch = "wasm32"))] Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(), #[cfg(not(target_arch = "wasm32"))] Message::SaveFileInUseSaveAs => self.on_save_file_in_use_save_as(), #[cfg(not(target_arch = "wasm32"))] Message::SaveFileInUseCancel => { self.close_active_modal(); Task::none() } #[cfg(not(target_arch = "wasm32"))] Message::ExternalChangeReload => self.on_external_change_reload(), #[cfg(not(target_arch = "wasm32"))] Message::ExternalChangeSaveAs => self.on_external_change_save_as(), #[cfg(not(target_arch = "wasm32"))] Message::ExternalChangeOverwrite => self.on_external_change_overwrite(), #[cfg(not(target_arch = "wasm32"))] Message::ExternalChangeCancel => { self.close_active_modal(); Task::none() } // ── Page Setup ──────────────────────────────────────────────── Message::UpdateCheckResult(latest) => { let Some(info) = latest else { return Task::none(); }; self.update_notice_version = Some(info.version); self.update_notice_body = Some(info.body); self.pending_startup_modals .push_back(super::ModalKind::UpdateNotice); Task::none() } Message::DonationPromptDonate => { self.close_active_modal(); self.dispatch_view("DONATE", self.active_tab) .unwrap_or_else(Task::none) } Message::UpdateNoticeClose => { self.close_active_modal(); Task::none() } Message::UpdateNoticeOpenRelease => { let open = crate::sys::open_url(crate::io::update_check::RELEASES_PAGE, self.main_window); self.close_active_modal(); open } Message::AssocPromptYes => { self.file_assoc_enabled = true; self.mark_assoc_prompted(); self.close_active_modal(); // set_default_app registers the handler first, then makes us the // default — boot no longer does this automatically. Task::perform( crate::io::file_association::set_default_app(), Message::AssocResult, ) } Message::AssocPromptNo => { self.file_assoc_enabled = false; self.mark_assoc_prompted(); self.close_active_modal(); Task::none() } Message::AssocResult(result) => { match result { Ok(msg) => self.command_line.push_info(&msg), Err(err) => self .command_line .push_error(crate::tf!("Could not set default app: {err}").as_ref()), } Task::none() } Message::PlotDialogOpen => self.on_plot_dialog_open(), Message::PlotDlg(m) => self.on_plot_dlg(m), Message::BlockPalette(m) => self.on_block_palette(m), Message::GraphicAttributes(m) => self.on_graphic_attributes(m), Message::Dock(m) => self.on_dock(m), Message::PrintAllOpen => self.on_print_all_open(), Message::PrintAllToggle(name) => { if let Some((_, selected)) = self .print_all_layouts .iter_mut() .find(|(layout, _)| layout == &name) { *selected = !*selected; } Task::none() } Message::PrintAllSelectAll => { for (_, selected) in &mut self.print_all_layouts { *selected = true; } Task::none() } Message::PrintAllSelectNone => { for (_, selected) in &mut self.print_all_layouts { *selected = false; } Task::none() } Message::PrintAllOptions => self.on_print_all_options(), Message::PrintAllPdf => { let i = self.active_tab; let stem = self.tabs[i] .current_path .as_deref() .and_then(|path| path.file_stem()) .map(|name| format!("{}_layouts", name.to_string_lossy())) .unwrap_or_else(|| "drawing_layouts".into()); #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { let Some(window_id) = self.main_window else { return Task::done(Message::PrintAllPdfPath(None)); }; iced::window::run(window_id, move |parent| { crate::io::pdf_export::pick_plot_path_owned(stem, parent, false) }) .map(Message::PrintAllPdfPath) } #[cfg(target_os = "windows")] { Task::perform( crate::io::pdf_export::pick_plot_path_async(stem, false), Message::PrintAllPdfPath, ) } #[cfg(target_arch = "wasm32")] { Task::perform( crate::io::pdf_export::pick_plot_path_owned(stem), Message::PrintAllPdfPath, ) } } Message::PrintAllPdfPath(None) => Task::none(), Message::PrintAllPdfPath(Some(path)) => self.on_print_all_pdf_path_some(path), Message::PrintAllPrint => self.on_print_all_print(), Message::PrintAllFinished(result) => { match result { Ok(message) => self.command_line.push_info(&message), Err(error) => { self.command_line.push_error(&error); if self.active_modal.is_none() { self.active_modal = Some(super::ModalKind::PrintAll); self.reset_modal_geometry(); } } } Task::none() } // ── Plot / Export ───────────────────────────────────────────── Message::PlotExport => { let i = self.active_tab; let stem = self.tabs[i] .current_path .as_deref() .and_then(|p: &std::path::Path| p.file_stem()) .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".into()); // The save dialog follows the dialog's file device, so the // SVG choice proposes `stem.svg` up front. #[cfg(not(target_arch = "wasm32"))] let svg = self.plot_dialog.file_svg; #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { let Some(window_id) = self.main_window else { return Task::done(Message::PlotExportPath(None)); }; iced::window::run(window_id, move |parent| { crate::io::pdf_export::pick_plot_path_owned(stem, parent, svg) }) .map(Message::PlotExportPath) } #[cfg(target_os = "windows")] { Task::perform( crate::io::pdf_export::pick_plot_path_async(stem, svg), Message::PlotExportPath, ) } #[cfg(target_arch = "wasm32")] { Task::perform( crate::io::pdf_export::pick_plot_path_owned(stem), Message::PlotExportPath, ) } } Message::PlotExportPath(None) => Task::none(), Message::PlotExportPath(Some(path)) => self.on_plot_export_path_some(path), Message::PlotWindowExport => { let i = self.active_tab; let stem = self.tabs[i] .current_path .as_deref() .and_then(|p: &std::path::Path| p.file_stem()) .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".into()); #[cfg(not(target_arch = "wasm32"))] let svg = self.plot_dialog.file_svg; #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { let Some(window_id) = self.main_window else { return Task::done(Message::PlotWindowExportPath(None)); }; iced::window::run(window_id, move |parent| { crate::io::pdf_export::pick_plot_path_owned(stem, parent, svg) }) .map(Message::PlotWindowExportPath) } #[cfg(target_os = "windows")] { Task::perform( crate::io::pdf_export::pick_plot_path_async(stem, svg), Message::PlotWindowExportPath, ) } #[cfg(target_arch = "wasm32")] { Task::perform( crate::io::pdf_export::pick_plot_path_owned(stem), Message::PlotWindowExportPath, ) } } Message::PlotWindowExportPath(None) => Task::none(), Message::PlotWindowExportPath(Some(path)) => self.on_plot_window_export_path_some(path), Message::BackgroundIoFinished(result, reopen_plot) => { match result { Ok(message) => self.command_line.push_info(&message), Err(error) => self.command_line.push_error(&error), } if reopen_plot { self.active_modal = Some(crate::app::ModalKind::Plot); } Task::none() } Message::BackgroundTaskFailed { context, detail } => { self.command_line .push_error(crate::tf!("{context} failed: {detail}").as_ref()); Task::none() } // ── Print to system printer ─────────────────────────────────────── Message::PrintToPrinter => self.on_print_to_printer(), Message::PrintResult(Ok(printer)) => { self.command_line .push_info(crate::tf!("Sent to printer: {printer}").as_ref()); Task::none() } Message::PrintResult(Err(e)) => { self.command_line .push_error(crate::tf!("Print failed: {e}").as_ref()); Task::none() } // ── Plot Style Table ────────────────────────────────────────────── Message::LinetypeLoaded(Some(source)) => { let i = self.active_tab; self.push_undo_snapshot(i, "LINETYPE"); let added = crate::io::linetypes::populate_document_from_source( &mut self.tabs[i].scene.document, &source, ); if added > 0 { self.tabs[i].dirty = true; self.tabs[i].scene.bump_geometry(); } self.command_line .push_output(crate::tf!("{added} linetype(s) loaded.").as_ref()); Task::none() } Message::LinetypeLoaded(None) => Task::none(), Message::PlotStyleLoad => { Task::perform(crate::io::pick_plot_style(), Message::PlotStyleLoaded) } Message::PlotStyleLoaded(Ok(Some(table))) => { if table.is_stb { self.command_line.push_error( crate::t!( "Named plot style tables are not supported by the vector plotter." ) .as_ref(), ); return Task::none(); } self.plot_dialog.style_name = table.name.clone(); self.plot_dialog.style_missing = false; self.plot_dialog.style_error = None; self.report_plot_style_warnings(&table); self.command_line.push_output( crate::tf!( "Plot style '{}' loaded ({} color entries).", table.name, table .aci_entries .iter() .filter(|e| e.color.is_some()) .count() ) .as_ref(), ); self.active_plot_style = Some(table); self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names(); Task::none() } Message::PlotStyleLoaded(Ok(None)) => Task::none(), Message::PlotStyleLoaded(Err(error)) => { // The file the user pointed at could not be read: say why, // in the dialog as well as on the command line. self.command_line.push_error(&error); self.plot_dialog.style_missing = true; self.plot_dialog.style_error = Some(error); Task::none() } Message::PlotStyleClear => { self.active_plot_style = None; self.plot_dialog.style_name.clear(); self.plot_dialog.style_missing = false; self.command_line .push_output(crate::t!("Plot style table cleared.").as_ref()); Task::none() } // ── Plot Style Panel ────────────────────────────────────────────── Message::PlotStylePanelOpen => { // The Plot dialog's selected table is authoritative. Make sure // the editor opens that table rather than a stale active table. let selected_style = self.plot_dialog.style_name.clone(); if selected_style.is_empty() { self.active_plot_style = None; } else { let needs_load = self .active_plot_style .as_ref() .is_none_or(|table| !table.name.eq_ignore_ascii_case(&selected_style)); if needs_load { match crate::io::plot_style::PlotStyleTable::load_named(&selected_style) { Ok(table) => { self.report_plot_style_warnings(&table); self.active_plot_style = Some(table); self.plot_dialog.style_missing = false; self.plot_dialog.style_error = None; } Err(error) => { self.plot_dialog.style_missing = true; self.command_line.push_error(&error); self.plot_dialog.style_error = Some(error); return Task::none(); } } } } // Initialise edit buffers for ACI 1. self.plotstyle_panel_aci = 1; let entry = self .active_plot_style .as_ref() .and_then(|t| t.aci_entries.get(1)); self.ps_color_buf = entry .and_then(|e| { e.color .map(|[r, g, b]| format!("#{:02X}{:02X}{:02X}", r, g, b)) }) .unwrap_or_default(); self.ps_lineweight_buf = entry .map(|e| e.lineweight.to_string()) .unwrap_or("255".into()); self.ps_screening_buf = entry .map(|e| e.screening.to_string()) .unwrap_or("100".into()); // When launched from PLOT, preserve the parent dialog so the Plot Style // editor can appear above it instead of replacing it. if self.active_modal == Some(super::ModalKind::Plot) { self.plotstyle_parent_plot_geometry = Some((self.modal_offset, self.modal_resize)); // The child editor starts with its own centred geometry. self.reset_modal_geometry(); } else { // Direct command launch: Plotstyle is a normal standalone modal. self.plotstyle_parent_plot_geometry = None; } self.active_modal = Some(super::ModalKind::Plotstyle); Task::none() } Message::PlotStylePanelClose => { self.close_active_modal(); Task::none() } Message::PlotStylePanelSelectAci(aci) => { self.plotstyle_panel_aci = aci; let entry = self .active_plot_style .as_ref() .and_then(|t| t.aci_entries.get(aci as usize)); self.ps_color_buf = entry .and_then(|e| { e.color .map(|[r, g, b]| format!("#{:02X}{:02X}{:02X}", r, g, b)) }) .unwrap_or_default(); self.ps_lineweight_buf = entry .map(|e| e.lineweight.to_string()) .unwrap_or("255".into()); self.ps_screening_buf = entry .map(|e| e.screening.to_string()) .unwrap_or("100".into()); Task::none() } Message::PlotStylePanelColorBuf(s) => { self.ps_color_buf = s; self.on_plot_style_panel_apply() } Message::PlotStylePanelLwBuf(s) => { self.ps_lineweight_buf = s; self.on_plot_style_panel_apply() } Message::PlotStylePanelLwSet(index) => { self.ps_lineweight_buf = index.to_string(); self.on_plot_style_panel_apply() } Message::PlotStylePanelScreenBuf(s) => { self.ps_screening_buf = s; self.on_plot_style_panel_apply() } Message::PlotStylePanelApply => self.on_plot_style_panel_apply(), Message::PlotStylePanelSaveDirect => { if self.active_plot_style.is_none() { self.command_line.push_error( crate::t!("No plot style table loaded. Load or create one first.").as_ref(), ); return Task::none(); } #[cfg(not(target_arch = "wasm32"))] { let table = self.active_plot_style.as_ref().expect("checked above"); let table_name = table.name.clone(); let result = crate::io::plot_style::ensure_plot_styles_dir().and_then(|dir| { let path = dir.join(&table_name); table.save(&path)?; Ok(path) }); match result { Ok(path) => { self.plot_dialog.style_name = table_name; self.plot_dialog.style_missing = false; self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names(); self.tabs[self.active_tab] .scene .invalidate_display_plot_style(); self.command_line.push_output( crate::tf!("Plot style table saved to \"{}\".", path.display()) .as_ref(), ); } Err(error) => { self.command_line .push_error(crate::tf!("Save error: {error}").as_ref()); } } Task::none() } #[cfg(target_arch = "wasm32")] { // Browsers cannot overwrite a local file directly, so fall back // to the existing Save As flow. self.on_plot_style_panel_save() } } Message::PlotStylePanelSave => self.on_plot_style_panel_save(), Message::PlotStylePanelSavePath(Some(path)) => { let path = if path .extension() .is_some_and(|extension| extension.eq_ignore_ascii_case("ctb")) { path } else { path.with_extension("ctb") }; if let Some(table) = &self.active_plot_style { match table.save(&path) { Ok(()) => { let name = path .file_name() .unwrap_or_default() .to_string_lossy() .into_owned(); if let Some(table) = self.active_plot_style.as_mut() { table.name = name.clone(); } self.plot_dialog.style_name = name; self.plot_dialog.style_missing = false; self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names(); self.tabs[self.active_tab] .scene .invalidate_display_plot_style(); self.command_line.push_output( crate::tf!("Plot style table saved to \"{}\".", path.display()) .as_ref(), ); } Err(e) => self .command_line .push_error(crate::tf!("Save error: {e}").as_ref()), } } Task::none() } Message::PlotStylePanelSavePath(None) => Task::none(), // ── TextStyle Font Browser ──────────────────────────────────────── Message::TextStyleDialogOpen => self.on_text_style_dialog_open(), Message::TextStyleDialogClose => { self.close_active_modal(); Task::none() } Message::TextStyleDialogSelect(name) => { self.stage_textstyle_bufs(); let i = self.active_tab; self.textstyle_selected = name; self.load_textstyle_bufs(i); Task::none() } Message::TextStyleDialogTab(tab) => { self.textstyle_tab = tab; Task::none() } Message::TextStyleDialogCompare(name) => { self.textstyle_compare = name; Task::none() } Message::TextStyleDialogSetCurrent => { // Staged: persists on Apply. let i = self.active_tab; let name = self.textstyle_selected.clone(); if self.tabs[i] .scene .document .text_styles .get(&name) .is_some_and(|style| !style.xref_dependent) { self.tabs[i].scene.document.header.current_text_style_name = name.clone(); self.sync_ribbon_styles(); self.command_line .push_output(crate::tf!("Current text style: {}", name).as_ref()); } Task::none() } Message::TextStyleDialogNew => self.style_new(crate::app::StyleKind::Text), Message::TextStyleDialogCopy => self.style_copy(crate::app::StyleKind::Text), Message::TextStyleDialogDelete => { self.style_delete(crate::app::StyleKind::Text); Task::none() } // ── Shared inline rename (all style managers) ───────────────── Message::StyleRenameStart(kind, name) => { self.style_rename_start(kind, name); // Focus the freshly-shown rename field so the user can type // immediately after the double click. iced::widget::operation::focus(crate::ui::style::style_list::rename_input_id()) } Message::StyleRenameEdit(s) => { self.style_rename_buf = s; Task::none() } Message::StyleRenameCommit(kind) => { self.style_rename_commit(kind); Task::none() } Message::StyleRenameFocus(focused) => { let typed = self .style_rename .as_ref() .is_some_and(|name| *name != self.style_rename_buf); if typed && focused != Some(crate::ui::style::style_list::rename_input_id()) { if let Some(kind) = self.style_rename_kind { self.style_rename_commit(kind); } } Task::none() } Message::StyleRenameCancel => { self.style_rename_cancel(); Task::none() } Message::TextStyleEdit { field, value } => { match field { "font" => self.textstyle_font = value, "width" => self.textstyle_width = value, "oblique" => self.textstyle_oblique = value, "height" => self.textstyle_height = value, "bigfont" => self.textstyle_bigfont = value, "ttf" => self.textstyle_ttf = value, _ => {} } Task::none() } Message::TextStyleToggle(field) => { // Staged: mutate live for preview, persist on Apply. let i = self.active_tab; let name = self.textstyle_selected.clone(); if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) { if s.xref_dependent { return Task::none(); } match field { "backward" => s.flags.backward = !s.flags.backward, "upside_down" => s.flags.upside_down = !s.flags.upside_down, "vertical" => s.is_vertical = !s.is_vertical, "annotative" => s.annotative = !s.annotative, _ => {} } } Task::none() } Message::TextStyleApply => self.on_text_style_apply(), Message::TextStyleFontPick(font_file) => { // Staged: update the buffer + live style; persist on Apply. let i = self.active_tab; let name = self.textstyle_selected.clone(); if self.tabs[i] .scene .document .text_styles .get(&name) .is_some_and(|style| style.xref_dependent) { return Task::none(); } self.textstyle_font = font_file.clone(); if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) { s.font_file = font_file; } Task::none() } // ── TableStyle Dialog ───────────────────────────────────────────── Message::TableStyleDialogOpen => { use codec::objects::ObjectType; let i = self.active_tab; self.tablestyle_selected = self.tabs[i] .scene .document .objects .values() .find_map(|o| { if let ObjectType::TableStyle(s) = o { Some(s.name.clone()) } else { None } }) .unwrap_or_else(|| "Standard".to_string()); self.load_tablestyle_bufs(i); self.active_modal = Some(super::ModalKind::TableStyle); self.style_stage_begin(); Task::none() } Message::TableStyleDialogClose => { self.close_active_modal(); Task::none() } Message::TableStyleDialogSelect(name) => { for row in 0..3 { let _ = self.on_table_style_cell_apply(row); } self.stage_tablestyle_bufs(); self.tablestyle_selected = name; let i = self.active_tab; self.load_tablestyle_bufs(i); Task::none() } Message::TableStyleDialogTab(tab) => { self.tablestyle_tab = tab; Task::none() } Message::TableStyleDialogCompare(name) => { self.tablestyle_compare = name; Task::none() } Message::TableStyleEdit { field, value } => { match field { "hmargin" => self.ts_hmargin = value, "vmargin" => self.ts_vmargin = value, "description" => self.ts_description = value, _ => {} } Task::none() } Message::TableStyleApply => { for row in 0..3 { let _ = self.on_table_style_cell_apply(row); } self.stage_tablestyle_bufs(); self.style_stage_commit(); Task::none() } Message::TableStyleSetFlow(value) => { use codec::objects::TableFlowDirection; let i = self.active_tab; if let Some(s) = self.tablestyle_mut(i) { s.flow_direction = match value.as_str() { "Up" => TableFlowDirection::Up, _ => TableFlowDirection::Down, }; } Task::none() } Message::TableColorMore(row, field) => { self.ts_color_open = if self.ts_color_open == Some((row, field)) { None } else { Some((row, field)) }; Task::none() } Message::TableStyleCellEdit { row, field, value } => { self.ts_color_open = None; let r = row as usize; if r < 3 { match field { "textstyle" => self.ts_cell_textstyle[r] = value, "height" => self.ts_cell_height[r] = value, "textcolor" => self.ts_cell_textcolor[r] = value, "fillcolor" => self.ts_cell_fillcolor[r] = value, "datatype" => self.ts_cell_datatype[r] = value, "unittype" => self.ts_cell_unittype[r] = value, "format" => self.ts_cell_format[r] = value, _ => {} } } Task::none() } Message::TableStyleBorderEdit { cell, border, field, value, } => { let (c, b) = (cell as usize, border as usize); if c < 3 && b < 6 { match field { "lw" => self.ts_border_lw[c][b] = value, "color" => self.ts_border_color[c][b] = value, "spacing" => self.ts_border_spacing[c][b] = value, _ => {} } } Task::none() } Message::TableStyleBorderSetType { cell, border, value, } => { use codec::objects::TableBorderType; let i = self.active_tab; if let Some(s) = self.tablestyle_mut(i) { if let Some(bd) = Self::ts_cell_of(s, cell).and_then(|c| Self::ts_border_of(c, border)) { bd.border_type = match value.as_str() { "Double" => TableBorderType::Double, _ => TableBorderType::Single, }; } } Task::none() } Message::TableStyleBorderToggleInvisible { cell, border } => { let i = self.active_tab; if let Some(s) = self.tablestyle_mut(i) { if let Some(bd) = Self::ts_cell_of(s, cell).and_then(|c| Self::ts_border_of(c, border)) { bd.is_invisible = !bd.is_invisible; } } Task::none() } Message::TableStyleCellToggleFill(row) => { let i = self.active_tab; if let Some(s) = self.tablestyle_mut(i) { if let Some(c) = Self::ts_cell_of(s, row) { c.fill_enabled = !c.fill_enabled; } } Task::none() } Message::TableStyleCellSetAlign { row, value } => { use codec::objects::CellAlignment; let i = self.active_tab; if let Some(s) = self.tablestyle_mut(i) { if let Some(c) = Self::ts_cell_of(s, row) { c.alignment = match value.as_str() { "TopLeft" => CellAlignment::TopLeft, "TopCenter" => CellAlignment::TopCenter, "TopRight" => CellAlignment::TopRight, "MiddleLeft" => CellAlignment::MiddleLeft, "MiddleRight" => CellAlignment::MiddleRight, "BottomLeft" => CellAlignment::BottomLeft, "BottomCenter" => CellAlignment::BottomCenter, "BottomRight" => CellAlignment::BottomRight, _ => CellAlignment::MiddleCenter, }; } } Task::none() } Message::TableStyleCellApply(row) => self.on_table_style_cell_apply(row), Message::TableStyleToggle(field) => { use codec::objects::ObjectType; let i = self.active_tab; let name = self.tablestyle_selected.clone(); for obj in self.tabs[i].scene.document.objects.values_mut() { if let ObjectType::TableStyle(s) = obj { if s.name == name { match field { "title_sup" => s.title_suppressed = !s.title_suppressed, "header_sup" => s.header_suppressed = !s.header_suppressed, _ => {} } } } } Task::none() } Message::TableStyleToggleAnnotative => { use codec::objects::ObjectType; let i = self.active_tab; let name = self.tablestyle_selected.clone(); for obj in self.tabs[i].scene.document.objects.values_mut() { if let ObjectType::TableStyle(s) = obj { if s.name == name { s.annotative = !s.annotative; } } } Task::none() } Message::TableStyleDialogNew => self.style_new(crate::app::StyleKind::Table), Message::TableStyleDialogCopy => self.style_copy(crate::app::StyleKind::Table), Message::TableStyleDialogDelete => { self.style_delete(crate::app::StyleKind::Table); Task::none() } Message::TableStyleDialogSetCurrent => { // Staged: persists on Apply. The header field is the round-trip // source of truth ($CTABLESTYLE); the ribbon mirrors it. let i = self.active_tab; let name = self.tablestyle_selected.clone(); if self .style_names(crate::app::StyleKind::Table) .contains(&name) { self.tabs[i].scene.document.header.current_table_style_name = name.clone(); self.ribbon.active_table_style = name.clone(); self.command_line .push_output(crate::tf!("Current table style: {name}").as_ref()); } Task::none() } // ── MLineStyle Dialog ───────────────────────────────────────────── Message::MlStyleDialogOpen => self.on_ml_style_dialog_open(), Message::MlStyleDialogClose => { self.close_active_modal(); Task::none() } Message::MlStyleDialogSelect(name) => { self.stage_mlstyle_bufs(); self.mlstyle_selected = name; let i = self.active_tab; self.load_mlstyle_bufs(i); Task::none() } Message::MlStyleDialogTab(tab) => { self.mlstyle_tab = tab; Task::none() } Message::MlStyleDialogCompare(name) => { self.mlstyle_compare = name; Task::none() } Message::MlStyleDialogSetCurrent => { use codec::objects::ObjectType; let i = self.active_tab; let name = self.mlstyle_selected.clone(); let exists = self.tabs[i] .scene .document .objects .values() .any(|o| matches!(o, ObjectType::MLineStyle(s) if s.name == name)); if exists { // Staged: persists on Apply. self.tabs[i].scene.document.header.multiline_style = name.clone(); self.command_line .push_output(crate::tf!("Current multiline style: {}", name).as_ref()); } Task::none() } Message::MlStyleApply => { self.stage_mlstyle_bufs(); self.style_stage_commit(); Task::none() } Message::MlStyleDialogNew => self.style_new(crate::app::StyleKind::MLine), Message::MlStyleDialogCopy => self.style_copy(crate::app::StyleKind::MLine), Message::MlStyleDialogDelete => { self.style_delete(crate::app::StyleKind::MLine); Task::none() } Message::MlStyleEdit { field, value } => { match field { "description" => self.mln_description = value, "start_angle" => self.mln_start_angle = value, "end_angle" => self.mln_end_angle = value, "fill_color" => self.mln_fill_color = value, _ => {} } self.stage_mlstyle_bufs(); Task::none() } Message::MlStyleToggle(field) => { let i = self.active_tab; if let Some(style) = self.mlstyle_mut(i) { match field { "fill" => style.flags.fill_on = !style.flags.fill_on, "joints" => style.flags.display_joints = !style.flags.display_joints, "start_square" => { style.flags.start_square_cap = !style.flags.start_square_cap } "start_inner" => { style.flags.start_inner_arcs_cap = !style.flags.start_inner_arcs_cap } "start_round" => style.flags.start_round_cap = !style.flags.start_round_cap, "end_square" => style.flags.end_square_cap = !style.flags.end_square_cap, "end_inner" => { style.flags.end_inner_arcs_cap = !style.flags.end_inner_arcs_cap } "end_round" => style.flags.end_round_cap = !style.flags.end_round_cap, _ => {} } } Task::none() } Message::MlStyleElementEdit { index, field, value, } => { if let Some(element) = self.mln_elements.get_mut(index) { match field { "offset" => element[0] = value, "color" => element[1] = value, "linetype" => element[2] = value, _ => {} } } self.stage_mlstyle_bufs(); Task::none() } Message::MlStyleElementAdd => { let i = self.active_tab; if let Some(style) = self.mlstyle_mut(i) { style .elements .push(codec::objects::MLineStyleElement::default()); } self.load_mlstyle_bufs(i); Task::none() } Message::MlStyleElementDelete(index) => { let i = self.active_tab; if let Some(style) = self.mlstyle_mut(i) { if style.elements.len() > 1 && index < style.elements.len() { style.elements.remove(index); } } self.load_mlstyle_bufs(i); Task::none() } // ── MLeaderStyle Dialog ─────────────────────────────────────────── Message::MLeaderStyleDialogOpen => self.on_mleader_style_dialog_open(), Message::MLeaderStyleDialogClose => { self.close_active_modal(); Task::none() } Message::MLeaderStyleDialogSelect(name) => { self.stage_mleaderstyle_bufs(); self.mleaderstyle_selected = name; let i = self.active_tab; self.load_mleaderstyle_bufs(i); Task::none() } Message::MLeaderStyleDialogTab(tab) => { self.mleaderstyle_tab = tab; Task::none() } Message::MLeaderStyleDialogCompare(name) => { self.mleaderstyle_compare = name; Task::none() } Message::MLeaderStyleDialogSetCurrent => self.on_mleader_style_dialog_set_current(), Message::MLeaderStyleDialogNew => self.style_new(crate::app::StyleKind::MLeader), Message::MLeaderStyleDialogCopy => self.style_copy(crate::app::StyleKind::MLeader), Message::MLeaderStyleDialogDelete => { self.style_delete(crate::app::StyleKind::MLeader); Task::none() } Message::MLeaderStyleEdit { field, value } => self.on_mleader_style_edit(field, value), Message::MLeaderStyleToggle(field) => { let i = self.active_tab; if let Some(s) = self.mleaderstyle_mut(i) { match field { "enable_landing" => s.enable_landing = !s.enable_landing, "enable_dogleg" => s.enable_dogleg = !s.enable_dogleg, "text_frame" => s.text_frame = !s.text_frame, "text_always_left" => s.text_always_left = !s.text_always_left, "annotative" => s.is_annotative = !s.is_annotative, "enable_block_scale" => s.enable_block_scale = !s.enable_block_scale, "enable_block_rotation" => { s.enable_block_rotation = !s.enable_block_rotation } _ => {} } } Task::none() } Message::MLeaderColorMore(field) => { self.mls_color_open = if self.mls_color_open == Some(field) { None } else { Some(field) }; Task::none() } Message::MLeaderStyleSetEnum { field, value } => { self.on_mleader_style_set_enum(field, value) } Message::MLeaderStyleLineWeightChanged(line_weight) => { let i = self.active_tab; if let Some(s) = self.mleaderstyle_mut(i) { s.line_weight = line_weight; } Task::none() } Message::MLeaderStyleSetHandle { field, value } => { self.on_mleader_style_set_handle(field, value) } Message::MLeaderStyleApply => self.on_mleader_style_apply(), // ── DimStyle Dialog ─────────────────────────────────────────────── Message::DimStyleDialogOpen => self.on_dim_style_dialog_open(), Message::DimStyleDialogClose => { self.close_active_modal(); Task::none() } Message::DimStyleDialogApply => { let i = self.active_tab; self.apply_dimstyle_bufs(i); self.style_stage_commit(); Task::none() } Message::DimStyleDialogSelect(name) => { let i = self.active_tab; // Stage the current edits before switching so they aren't lost. self.apply_dimstyle_bufs(i); self.dimstyle_selected = name; self.load_dimstyle_bufs(i); Task::none() } Message::DimStyleDialogTab(tab) => { self.dimstyle_tab = tab; Task::none() } Message::DimStyleDialogCompare(name) => { self.dimstyle_compare = name; Task::none() } Message::DimStyleDialogNew => self.style_new(crate::app::StyleKind::Dim), Message::DimStyleDialogCopy => self.style_copy(crate::app::StyleKind::Dim), Message::DimStyleDialogSetCurrent => { // Staged: persists on Apply. let i = self.active_tab; self.tabs[i].scene.document.header.current_dimstyle_name = self.dimstyle_selected.clone(); self.sync_ribbon_styles(); self.command_line.push_output( crate::tf!("Current dim style set to '{}'.", self.dimstyle_selected).as_ref(), ); Task::none() } Message::DimStyleDialogDelete => { self.style_delete(crate::app::StyleKind::Dim); Task::none() } Message::DsEdit(field, val) => { self.apply_ds_edit(field, val); self.ds_color_open = None; Task::none() } Message::DsToggle(field) => { let separate_arrows = field == crate::app::DsField::Dimsah; self.apply_ds_toggle(field); if separate_arrows && self.ds_dimsah { let i = self.active_tab; if let Some(style) = self.tabs[i] .scene .document .dim_styles .get_mut(&self.dimstyle_selected) { if style.dimblk1.is_null() { style.dimblk1 = style.dimblk; } if style.dimblk2.is_null() { style.dimblk2 = style.dimblk1; } } } Task::none() } Message::DsToleranceMode(mode) => { self.ds_dimlim = mode == "limits"; self.ds_dimtol = matches!(mode.as_str(), "symmetrical" | "deviation"); if mode == "symmetrical" { self.ds_dimtm = self.ds_dimtp.clone(); } let gap = self.ds_dimgap.trim().parse::().unwrap_or(0.625).abs(); self.ds_dimgap = if mode == "basic" { format!("-{}", gap.max(f64::EPSILON)) } else { format!("{}", gap) }; Task::none() } Message::DsZeroBase(field, base) => { let current = match &field { crate::app::DsField::Dimzin => &self.ds_dimzin, crate::app::DsField::Dimaltz => &self.ds_dimaltz, crate::app::DsField::Dimalttz => &self.ds_dimalttz, crate::app::DsField::Dimtzin => &self.ds_dimtzin, _ => return Task::none(), } .trim() .parse::() .unwrap_or(0); self.apply_ds_edit(field, ((current & !3) | (base & 3)).to_string()); Task::none() } Message::DsZeroFlag(field, bit) => { let current = match &field { crate::app::DsField::Dimzin => &self.ds_dimzin, crate::app::DsField::Dimaltz => &self.ds_dimaltz, crate::app::DsField::Dimalttz => &self.ds_dimalttz, crate::app::DsField::Dimtzin => &self.ds_dimtzin, _ => return Task::none(), } .trim() .parse::() .unwrap_or(0); self.apply_ds_edit(field, (current ^ bit).to_string()); Task::none() } Message::DsCenterMarkMode(mode) => { let size = self.ds_dimcen.trim().parse::().unwrap_or(0.09).abs(); self.ds_dimcen = match mode.as_str() { "mark" => size.max(f64::EPSILON).to_string(), "lines" => format!("-{}", size.max(f64::EPSILON)), _ => "0".to_string(), }; Task::none() } Message::DsColorMore(field) => { self.ds_color_open = if self.ds_color_open.as_ref() == Some(&field) { None } else { Some(field) }; Task::none() } Message::OpenColorWindow(target, color) => { if matches!( target, crate::app::ColorPickTarget::GraphicAttributesLine | crate::app::ColorPickTarget::GraphicAttributesSolid ) { self.graphic_attributes.open_menu = None; } let (current, varies, name) = self.color_picker_current(&target, color); let mut picker = crate::ui::color_select::ColorPicker::new(target, current, varies); if !matches!(picker.target, crate::app::ColorPickTarget::Interface(_)) { self.read_color_books(); let by_layer = self.color_picker_layer_color(&picker.target); let tab = &self.tabs[self.active_tab]; picker = picker.with_drawing( crate::ui::color_select::document::collect(&tab.scene.document), &tab.recent_colors, std::sync::Arc::clone(&self.color_books), name, by_layer, ); } self.color_picker = Some(picker); self.modal_offset = iced::Vector::ZERO; self.ds_color_open = None; self.mls_color_open = None; self.ts_color_open = None; self.ribbon.close_dropdown(); let i = self.active_tab; self.tabs[i].properties.color_picker_open = false; self.tabs[i].properties.open_color_field = None; self.tabs[i].layers.color_picker_row = None; Task::none() } Message::ColorPicker(msg) => { let Some(picker) = self.color_picker.as_mut() else { return Task::none(); }; match picker.update(msg) { Some(crate::ui::color_select::Outcome::Commit(color, name)) => { self.update(Message::ColorWindowPick(color, name)) } Some(crate::ui::color_select::Outcome::LoadBook) => Task::perform( crate::ui::color_select::book::load_from_dialog(), Message::ColorBookLoaded, ), Some(crate::ui::color_select::Outcome::Cancel) => { self.color_picker = None; Task::none() } Some(crate::ui::color_select::Outcome::Screenshot) => { use crate::ui::color_select::{eyedropper::EyedropperMsg, ColorPickerMsg}; match self.main_window { Some(id) => iced::window::screenshot(id).map(|shot| { Message::ColorPicker(ColorPickerMsg::Eyedropper( EyedropperMsg::Shot(shot), )) }), // Nothing to capture: leave the eyedropper instead // of waiting for a screenshot that never comes. None => self.update(Message::ColorPicker(ColorPickerMsg::Cancel)), } } None => Task::none(), } } Message::CloseColorPicker => { self.color_picker = None; Task::none() } Message::ColorWindowPick(color, name) => { // Interface colours are not drawing colours: keep them out of // the recent list. if !matches!( self.color_picker.as_ref().map(|picker| &picker.target), Some(crate::app::ColorPickTarget::Interface(_)) ) { self.note_recent_color(color); } self.on_color_window_pick(color, name) } Message::ColorBookLoaded(Ok(book)) => { // A book loaded again replaces the earlier copy. let index = crate::ui::color_select::book::add_or_replace( std::sync::Arc::make_mut(&mut self.color_books), book, ); if let Some(picker) = self.color_picker.as_mut() { picker.show_book(std::sync::Arc::clone(&self.color_books), index); } Task::none() } Message::ColorBookLoaded(Err(error)) => { if let Some(picker) = self.color_picker.as_mut().filter(|_| error != "Cancelled") { picker.set_status(error); } Task::none() } Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value), } } /// The colour the Select Color panel shows as "Current", and whether the /// selection it edits has mixed colours. A mixed selection starts from the /// colour of its most recently drawn object (the highest handle). /// Also returns the colour-book identity (`BOOK$NAME`) of that colour. fn color_picker_current( &self, target: &crate::app::ColorPickTarget, color: codec::types::Color, ) -> (codec::types::Color, bool, Option) { let i = self.active_tab; let document = &self.tabs[i].scene.document; let name_of = |handle| { document .get_entity(handle) .and_then(|entity| entity.common().color_name.clone()) }; match target { crate::app::ColorPickTarget::Properties => {} crate::app::ColorPickTarget::Ribbon => { return (color, false, self.ribbon.active_color_name()); } crate::app::ColorPickTarget::Layer(layer) => { let name = document .layers .get(layer) .and_then(crate::ui::color_select::document::layer_identity); return (color, false, name); } _ => return (color, false, None), } let mut colors = self .property_target_handles(i) .into_iter() .filter_map(|handle| { document .get_entity(handle) .map(|entity| (handle, entity.common().color)) }) .collect::>(); let Some(&(first_handle, first)) = colors.first() else { return (color, false, None); }; if colors.iter().all(|&(_, c)| c == first) { return (first, false, name_of(first_handle)); } colors.sort_by_key(|&(handle, _)| handle); let (newest_handle, newest) = colors.last().copied().unwrap_or((first_handle, color)); (newest, true, name_of(newest_handle)) } /// The colour ByLayer stands for in the Select Color panel: the layer of /// the newest selected object, or the current layer for new objects. fn color_picker_layer_color( &self, target: &crate::app::ColorPickTarget, ) -> Option { let i = self.active_tab; let document = &self.tabs[i].scene.document; let layer = match target { crate::app::ColorPickTarget::Properties => self .property_target_handles(i) .into_iter() .max() .and_then(|handle| document.get_entity(handle)) .map_or(self.tabs[i].active_layer.as_str(), |entity| { entity.as_entity().layer() }), crate::app::ColorPickTarget::Ribbon => self.tabs[i].active_layer.as_str(), _ => return None, }; document.layers.get(layer).map(|layer| layer.color) } /// Read the built-in colour book and every `.acb` in the colour book /// folder, once per session. A book in the folder replaces a built-in /// one of the same name. fn read_color_books(&mut self) { if std::mem::replace(&mut self.color_books_read, true) { return; } self.color_books = std::sync::Arc::new(crate::ui::color_select::book::read_all()); } /// Colour the selected objects (or set the current colour when nothing /// is selected). `name` is a colour-book identity, `BOOK$NAME`. pub(super) fn set_property_color( &mut self, color: codec::types::Color, name: Option, ) -> Task { let i = self.active_tab; let handles = self.property_target_handles(i); if handles.is_empty() { let task = self.on_ribbon_color_changed(color); self.ribbon.active_color_book = name.map(|name| (color, name)); self.refresh_properties(); return task; } 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_named_color(entity, color, name.clone()); } }); self.tabs[i].properties.color_picker_open = false; Task::none() } /// Colour every selected layer (multi-select), not just one. `name` is a /// colour-book identity, `BOOK$NAME`. pub(super) fn set_layer_color( &mut self, color: codec::types::Color, name: Option<&str>, ) -> Task { let i = self.active_tab; // Layers keep the book and the colour name apart. let (book_name, color_name) = match name.map(|name| name.split_once('$')) { Some(Some((book, name))) => (Some(book), Some(name)), Some(None) => (None, name), None => (None, None), }; let names = self.selected_layer_names(i); if !names.is_empty() { let undo = self.begin_layer_undo(i, "LAYER COLOR", &names); for name in &names { if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { dl.color = color; dl.color_name = color_name.map(str::to_string); dl.book_name = book_name.map(str::to_string); } } for pl in self.tabs[i].layers.layers.iter_mut() { if names.contains(&pl.name) { pl.color = color; pl.color_name = color_name.map(str::to_string); pl.book_name = book_name.map(str::to_string); } } self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); // ByLayer color is baked into the cached wires at // tessellation time, so bump the geometry epoch to // invalidate the wire cache and repaint with the new color. self.tabs[i].scene.invalidate_layer_dependencies(&names); self.tabs[i].layers.color_picker_row = None; self.tabs[i].layers.color_full_palette = false; self.sync_ribbon_layers(); } Task::none() } pub(crate) fn note_recent_color(&mut self, color: codec::types::Color) { // Keep only real colours in the recent list. ByLayer / ByBlock / None // are logical CAD states rather than reusable colours. if matches!( &color, codec::types::Color::Index(_) | codec::types::Color::Rgb { .. } ) { // No duplicates: selecting an existing colour moves it to the front. let recent = &mut self.tabs[self.active_tab].recent_colors; if let Some(pos) = recent.iter().position(|c| c == &color) { recent.remove(pos); } recent.insert(0, color); recent.truncate(12); } } /// Load a named scale into the scale-manager editor buffers (name + the /// paper / drawing units); blank ratios when the scale isn't found. fn load_scale_editor(&mut self, name: &str) { let i = self.active_tab; self.scale_manager_selected = name.to_string(); match self.tabs[i].scene.scale_paper_drawing(name) { Some((p, d)) => { self.scale_manager_paper_buf = format!("{p}"); self.scale_manager_drawing_buf = format!("{d}"); } None => { self.scale_manager_paper_buf.clear(); self.scale_manager_drawing_buf.clear(); } } } /// Fold the editor's paper:drawing ratio into the selected scale, keeping /// its name (renaming is done inline in the list). Staged, no commit — so /// the ratio edit survives Apply *and* switching to another row. Editing a /// built-in fallback scale materialises it as a real one. fn scale_apply_current(&mut self) { let i = self.active_tab; let sel = self.scale_manager_selected.clone(); if sel.is_empty() { return; } let paper = self.scale_manager_paper_buf.trim().parse::().ok(); let drawing = self.scale_manager_drawing_buf.trim().parse::().ok(); if let (Some(paper), Some(drawing)) = (paper, drawing) { if paper > 0.0 && drawing > 0.0 { // Skip when the editor still holds the stored ratio, so merely // navigating between scales doesn't dirty the drawing. if let Some((cp, cd)) = self.tabs[i].scene.scale_paper_drawing(&sel) { if (cp - paper).abs() < 1e-9 && (cd - drawing).abs() < 1e-9 { return; } } let changed = self.tabs[i].scene.edit_scale(&sel, &sel, paper, drawing) || (self.tabs[i].scene.scale_paper_drawing(&sel).is_none() && self.tabs[i].scene.add_scale(&sel, paper, drawing)); if changed { self.scale_stage_mark(); } } } } /// Write the table-style editor buffers (margins / description) into the /// selected style (staged, no commit), so edits survive switching as well /// as Apply. fn stage_tablestyle_bufs(&mut self) { use codec::objects::ObjectType; let i = self.active_tab; let name = self.tablestyle_selected.clone(); let h: Option = self.ts_hmargin.trim().parse().ok(); let v: Option = self.ts_vmargin.trim().parse().ok(); let desc = self.ts_description.clone(); for obj in self.tabs[i].scene.document.objects.values_mut() { if let ObjectType::TableStyle(s) = obj { if s.name == name { if let Some(h) = h { s.horizontal_margin = h; } if let Some(v) = v { s.vertical_margin = v; } s.description = desc.clone(); } } } } /// A scale name based on `base`, suffixed " (n)" until it's unique in the /// drawing's scale list (used by New / Copy). fn unique_scale_name(&self, base: &str) -> String { let existing: std::collections::HashSet = self.tabs[self.active_tab] .scene .scale_list() .into_iter() .map(|(n, _, _)| n.to_ascii_lowercase()) .collect(); if !existing.contains(&base.to_ascii_lowercase()) { return base.to_string(); } let mut n = 2; loop { let candidate = format!("{base} ({n})"); if !existing.contains(&candidate.to_ascii_lowercase()) { return candidate; } n += 1; } } } #[cfg(test)] mod prop_pointer_tests { use super::Message; use crate::app::OpenCADStudio; use crate::ui::dock::PanelId; fn drawing_app() -> OpenCADStudio { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app } #[test] fn hidden_properties_panel_skips_the_focus_sweep() { // A click while the panel is closed must not run the widget-tree sweep: // the returned task is a bare `Task::none` (units == 0). let mut app = drawing_app(); assert!(app.dock_panel_visible(PanelId::Properties)); app.show_properties = false; assert!(!app.dock_panel_visible(PanelId::Properties)); let task = app.update(Message::PropPointerPressed); assert_eq!(task.units(), 0); } #[test] fn visible_properties_panel_runs_the_focus_sweep() { // A click while the panel is open must still fire the sweep (units > 0) // so the select-whole-value feature keeps working. let mut app = drawing_app(); app.show_properties = true; assert!(app.dock_panel_visible(PanelId::Properties)); let task = app.update(Message::PropPointerPressed); assert!(task.units() > 0); } } #[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 = 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 = 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 = 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) //! change what Space / Enter / typing mean at the command line. These //! tests pin the routing through `text_entry_mode` so the three key //! routes can't drift apart again. use super::Message; use crate::app::{OpenCADStudio, TextEntryMode}; use crate::modules::annotate::table_cmd::TableCellEditCommand; use codec::entities::Table; use codec::types::Vector3; use codec::{EntityType, Handle}; /// A test drawing with a 2×2 table and the cell editor active on [0,0], /// seeded with `cell_text`. fn app_with_cell_command(cell_text: &str) -> (OpenCADStudio, Handle) { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let mut table = Table::new(Vector3::new(0.0, 0.0, 0.0), 2, 2); table.cell_mut(0, 0).unwrap().set_text(cell_text); let handle = app.tabs[0] .scene .document .add_entity(EntityType::Table(Box::new(table.clone()))) .unwrap(); app.set_active_command(0, Box::new(TableCellEditCommand::new(handle, &table, 0, 0))); (app, handle) } fn cell_text(app: &OpenCADStudio, _handle: Handle) -> String { // ReplaceMany commits by erasing the old handle and re-adding the // entity under a new one, so look the (single) table up by type. app.tabs[0] .scene .document .entities() .find_map(|e| match e { EntityType::Table(table) => Some(table.cell_text(0, 0).unwrap_or("").to_string()), _ => None, }) .expect("table entity missing") } #[test] fn cell_editor_switches_to_free_text_mode() { let (mut app, _) = app_with_cell_command(""); assert_eq!(app.text_entry_mode(), TextEntryMode::FreeText); assert!(app.is_free_text_active()); app.tabs[0].active_cmd = None; assert_eq!(app.text_entry_mode(), TextEntryMode::Command); assert!(!app.is_free_text_active()); } #[test] fn cell_edit_typed_space_stays_in_buffer_with_case() { let (mut app, handle) = app_with_cell_command(""); // A typed buffer containing a Space must not auto-submit. let _ = app.update(Message::CommandInput("Foo Bar".into())); assert_eq!(app.command_line.input, "Foo Bar"); assert!(app.tabs[0].active_cmd.is_some(), "Space must not submit"); // The Space key binding inserts a literal space too. let _ = app.update(Message::CommandSpace); assert_eq!(app.command_line.input, "Foo Bar "); // Enter finishes the edit and commits the content, case intact. let _ = app.update(Message::CommandSubmit); assert_eq!(cell_text(&app, handle), "Foo Bar"); } #[test] fn cell_edit_slash_n_inserts_line_break() { let (mut app, handle) = app_with_cell_command(""); let _ = app.update(Message::CommandInput("Foo/nBar".into())); let _ = app.update(Message::CommandSubmit); assert_eq!(cell_text(&app, handle), "Foo\\PBar"); } #[test] fn cell_edit_unfocused_typing_preserves_case() { // The CommandAppendChar route (typing while the command line is not // focused) must not uppercase free-form text either. let (mut app, handle) = app_with_cell_command(""); for ch in ["F", "o", "o"] { let _ = app.update(Message::CommandAppendChar(ch.into())); } let _ = app.update(Message::CommandSubmit); assert_eq!(cell_text(&app, handle), "Foo"); } #[test] fn cell_edit_skips_expression_evaluation() { let (mut app, handle) = app_with_cell_command(""); let _ = app.update(Message::CommandInput("5*2".into())); let _ = app.update(Message::CommandSubmit); assert_eq!(cell_text(&app, handle), "5*2"); } #[test] fn cell_edit_leading_greater_than_is_content() { // `>` is only a literal-space hint for command prompts; in free // text it is content and must not be stripped on submit. let (mut app, handle) = app_with_cell_command(""); let _ = app.update(Message::CommandInput(">Note".into())); let _ = app.update(Message::CommandSubmit); assert_eq!(cell_text(&app, handle), ">Note"); } #[test] fn cell_edit_empty_enter_ends_edit_without_wiping() { let (mut app, handle) = app_with_cell_command("Keep me"); let _ = app.update(Message::CommandSubmit); assert_eq!(cell_text(&app, handle), "Keep me"); assert!(app.tabs[0].active_cmd.is_none(), "Enter ends the edit"); } #[test] fn normal_commands_still_uppercase_and_submit_on_space() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); // No free-text command active: entry is uppercased… let _ = app.update(Message::CommandInput("lin".into())); assert_eq!(app.command_line.input, "LIN"); // …and a pasted multi-token line runs as a command, not as text. let _ = app.update(Message::CommandInput("LINE 0,0 10,10".into())); assert!( app.command_line.input.is_empty(), "Space submitted the line" ); assert_eq!(app.text_entry_mode(), TextEntryMode::Command); } #[test] fn literal_command_input_preserves_case() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let _ = app.update(Message::CommandInput(">Plugin MixedCase".into())); assert_eq!(app.command_line.input, ">Plugin MixedCase"); } #[test] fn paste_shortcut_leaves_a_focused_field_alone() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.command_line.input = "EXISTING".into(); 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, )); assert_eq!(app.command_line.input, "EXISTING"); assert!(app.tabs[0].active_cmd.is_none()); } #[test] fn paste_shortcut_over_the_command_line_pastes_copied_objects() { use crate::app::PasteFocus; let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); // Text only: the command line already pasted it natively. let _ = app.update(Message::PasteShortcutResolved(PasteFocus::CommandLine)); assert!(app.command_line.input.is_empty()); assert!(app.tabs[0].active_cmd.is_none()); // 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")); } #[test] fn space_bound_to_finalize_finalizes_in_free_text_mode() { // #1710: when the user rebinds SPACE to FINALIZE in the CUI, pressing // SPACE must run FINALIZE even while the command line holds focus, // instead of falling through to the command line's CommandSpace // (space-insert) behavior. FreeText mode distinguishes the two: a // COMMANDSPACE would insert a space, while FINALIZE finishes the edit. let (mut app, _) = app_with_cell_command(""); assert_eq!(app.text_entry_mode(), TextEntryMode::FreeText); // Bind SPACE to FINALIZE (the CUI assignment from the issue). app.shortcut_bindings .insert("SPACE".to_string(), "FINALIZE".to_string()); // Pressing SPACE runs FINALIZE, which finishes the edit instead of // inserting a space. let _ = app.update(Message::ShortcutPressed("SPACE".to_string())); assert!( app.tabs[0].active_cmd.is_none(), "SPACE bound to FINALIZE must finalize, not insert a space" ); } } /// Set or clear one bit of `SELECTIONPREVIEW`. fn set_preview_bit(current: u8, bit: u8, enabled: bool) -> u8 { if enabled { current | bit } else { current & !bit } } /// Web autosave deadline (SAVETIME minutes), polled from a short tick: the /// first call arms it, a later one past it re-arms it and answers true. #[cfg(target_arch = "wasm32")] fn web_autosave_due(savetime_min: i32) -> bool { use std::cell::Cell; thread_local!(static DUE_MS: Cell = const { Cell::new(0.0) }); let now = js_sys::Date::now(); let period = savetime_min.max(0) as f64 * 60_000.0; DUE_MS.with(|due| { if period == 0.0 || due.get() == 0.0 { due.set(if period == 0.0 { 0.0 } else { now + period }); return false; } let fire = now >= due.get(); if fire { due.set(now + period); } fire }) }