forked from 1b-agent/opencadstudio-upstream
Display gets a command line text colour (blank keeps the theme's); it covers history, prompts and the input, while errors stay red. Files lists the fonts folder that text resolution searches, so .shx files can be added by hand; opening it creates it first. Discussions #1493, #1372 Co-Authored-By: Claude Opus 5.5 <[email protected]>
11270 lines
492 KiB
Rust
11270 lines
492 KiB
Rust
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::DynTabNext
|
||
| 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::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<usize> {
|
||
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)
|
||
}
|
||
|
||
mod command;
|
||
mod context_menu;
|
||
mod blocks_palette;
|
||
mod dialog;
|
||
mod dynamic;
|
||
mod file;
|
||
pub(in crate::app) use file::background_task;
|
||
mod page_setup_import;
|
||
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(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(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<Message> {
|
||
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);
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
pub fn update(&mut self, msg: Message) -> Task<Message> {
|
||
// 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();
|
||
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 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<Message> {
|
||
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)
|
||
});
|
||
// 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::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();
|
||
self.show_next_startup_modal();
|
||
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.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())
|
||
.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();
|
||
// 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_ms = started.elapsed().as_secs_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));
|
||
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<Message> {
|
||
match msg {
|
||
Message::SpaceMouseWake => self.on_spacemouse_wake(),
|
||
Message::TrackpadPinch(magnification) => self.on_pinch_zoom(magnification),
|
||
Message::SpaceMouseFrame(time) => {
|
||
self.spacemouse.frame(
|
||
time.saturating_duration_since(self.start).as_secs_f64() * 1000.,
|
||
);
|
||
Task::none()
|
||
}
|
||
Message::SpaceMouseFocus(id, focused) => {
|
||
if Some(id) == self.main_window {
|
||
self.spacemouse_focused = focused;
|
||
}
|
||
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 => {
|
||
if let Some(request) = super::control::web_request() {
|
||
self.update(Message::ControlRequest(request))
|
||
} else {
|
||
Task::none()
|
||
}
|
||
}
|
||
#[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::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 => {
|
||
let pending = crate::scene::text::web_font::take_pending();
|
||
if pending.is_empty() {
|
||
return Task::none();
|
||
}
|
||
Task::batch(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());
|
||
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<String>,
|
||
found: Option<String>,
|
||
}
|
||
impl Operation<Option<String>> 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<Option<String>>),
|
||
) {
|
||
operate(self);
|
||
}
|
||
fn finish(&self) -> Outcome<Option<String>> {
|
||
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.snap_override_popup = None;
|
||
self.snapper.set_override(t);
|
||
let label = crate::snap::ALL_SNAP_MODES
|
||
.iter()
|
||
.find(|(m, _, _)| *m == t)
|
||
.map(|(_, _, l)| *l)
|
||
.unwrap_or("Snap");
|
||
self.command_line
|
||
.push_info(crate::tf!("Snap override: {label} (next pick only).").as_ref());
|
||
Task::none()
|
||
}
|
||
|
||
Message::SnapOverrideNone => {
|
||
self.snap_override_popup = None;
|
||
self.snapper.set_override_none();
|
||
self.command_line
|
||
.push_info(crate::t!("Snap override: None (next pick only).").as_ref());
|
||
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::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 => {
|
||
if let Some(p) = self.opening.take() {
|
||
self.command_line
|
||
.push_info(crate::tf!("Open cancelled: \"{}\"", p.name).as_ref());
|
||
}
|
||
self.drain_pending_open()
|
||
}
|
||
|
||
#[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();
|
||
}
|
||
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();
|
||
}
|
||
self.on_file_opened(name, path, doc, caches)
|
||
}
|
||
|
||
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()
|
||
}
|
||
},
|
||
|
||
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<String> = pairs
|
||
.iter()
|
||
.map(|(name, _)| crate::io::font_repo::font_key(name))
|
||
.collect();
|
||
let unavailable: Vec<String> = 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 => 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();
|
||
Task::perform(
|
||
async move {
|
||
let path = crate::sys::file_dialog()
|
||
.set_title(crate::t!("Save Block As").as_ref())
|
||
.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),
|
||
)
|
||
}
|
||
|
||
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) => {
|
||
match result {
|
||
Ok(()) => self.command_line.push_output(
|
||
crate::tf!("WBLOCK Saved \"{block_name}\" → \"{}\"", path.display())
|
||
.as_ref(),
|
||
),
|
||
Err(error) => self
|
||
.command_line
|
||
.push_error(crate::tf!("WBLOCK save failed: {error}").as_ref()),
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
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(paths) => {
|
||
self.on_data_extraction_add_drawings_result(paths)
|
||
}
|
||
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)) => {
|
||
match std::fs::write(&path, csv.as_bytes()) {
|
||
Ok(()) => {
|
||
let rows = csv.lines().count().saturating_sub(1);
|
||
let fname = path
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||
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();
|
||
}
|
||
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,
|
||
)
|
||
}
|
||
|
||
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();
|
||
}
|
||
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,
|
||
)
|
||
}
|
||
|
||
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 ────────────────────────────────────────────────
|
||
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,
|
||
),
|
||
|
||
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::PcManager(message) => self.update_pc_manager(message),
|
||
Message::Count(message) => self.on_count(message),
|
||
Message::SheetSet(message) => self.on_sheet_set(message),
|
||
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 "<command> <path>" 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<String> = 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) =
|
||
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||
if cmd.on_live_input(&live_input) {
|
||
(true, cmd.options(), cmd.prompt())
|
||
} else {
|
||
(false, Vec::new(), String::new())
|
||
}
|
||
} else {
|
||
(false, Vec::new(), String::new())
|
||
};
|
||
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 => {
|
||
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::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.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.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::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::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(cmd) => {
|
||
self.command_line.input.clear();
|
||
self.command_line.autocomplete_cursor = None;
|
||
self.command_line.close_history();
|
||
self.dispatch_command(&cmd)
|
||
}
|
||
|
||
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 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),
|
||
|
||
Message::ToggleLayers => {
|
||
if self.active_modal == Some(super::ModalKind::Layers) {
|
||
self.ribbon.deactivate_tool_if("LAYERS");
|
||
self.active_modal = None;
|
||
self.reset_modal_geometry();
|
||
} else {
|
||
self.sync_ribbon_layers();
|
||
self.active_modal = Some(super::ModalKind::Layers);
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
Message::ToggleXrefManager => {
|
||
self.show_external_references ^= true;
|
||
if self.show_external_references {
|
||
use crate::app::config::DockSide;
|
||
use crate::ui::dock::PanelId;
|
||
if self.dock.location(PanelId::ExternalReferences).is_none() {
|
||
self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX);
|
||
}
|
||
self.dock_expanded = Some(PanelId::ExternalReferences);
|
||
self.refresh_xref_manager();
|
||
}
|
||
Task::none()
|
||
}
|
||
Message::XrefManagerRefresh => {
|
||
self.refresh_xref_manager();
|
||
Task::none()
|
||
}
|
||
Message::XrefManagerSelect(index) => {
|
||
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(index, extend);
|
||
Task::none()
|
||
}
|
||
Message::XrefRowRightClick(index) => {
|
||
self.xref_manager.row_change_path_open = false;
|
||
self.xref_manager.right_click_select(index);
|
||
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(index, op) => {
|
||
// Open is not a palette op — it navigates to the file.
|
||
if op == crate::ui::window::xref_manager::XrefPaletteOp::Open {
|
||
self.xref_manager.right_click_select(index);
|
||
self.xref_manager.row_change_path_open = false;
|
||
if let Some(entry) = self.xref_manager.entries.get(index) {
|
||
if let Some(found) = entry.found_at.clone() {
|
||
let is_dwg = found.to_ascii_lowercase().ends_with(".dwg")
|
||
|| found.to_ascii_lowercase().ends_with(".dxf");
|
||
self.command_line.push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref());
|
||
if is_dwg {
|
||
return self.update(Message::OpenRecent(std::path::PathBuf::from(found)));
|
||
} 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(index);
|
||
self.xref_manager.row_change_path_open = false;
|
||
self.command_line.push_output(crate::t!("XATTACH").as_ref());
|
||
return self.update(Message::XAttachPick);
|
||
}
|
||
// Row-scoped op: select the row first, then run the op.
|
||
self.xref_manager.right_click_select(index);
|
||
self.xref_manager.row_change_path_open = false;
|
||
self.xref_manager_op(op);
|
||
Task::none()
|
||
}
|
||
Message::XrefRowPathPick(index) => {
|
||
self.xref_manager.right_click_select(index);
|
||
self.xref_manager.row_change_path_open = false;
|
||
self.update(Message::XrefPathPick)
|
||
}
|
||
Message::XrefRowFindReplacePrompt(index) => {
|
||
self.xref_manager.right_click_select(index);
|
||
self.xref_manager.row_change_path_open = false;
|
||
let prefill = if let Some(entry) = self.xref_manager.entries.get(index) {
|
||
let saved = &entry.saved_path;
|
||
if let Some(parent) = std::path::Path::new(saved).parent().and_then(|p| p.to_str()) {
|
||
if !parent.is_empty() {
|
||
format!("XREF Path Find \"{}\" ", parent)
|
||
} else {
|
||
"XREF Path Find ".to_string()
|
||
}
|
||
} else {
|
||
"XREF Path Find ".to_string()
|
||
}
|
||
} 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 <old> <new>").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 <old> <new>` 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<String> = 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 => {
|
||
Task::perform(crate::io::pick_layer_standard_path(), |path| match path {
|
||
Some(path) => Message::LayerTranslatorLoaded(path),
|
||
None => Message::Noop,
|
||
})
|
||
}
|
||
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<String> =
|
||
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<String> = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.layers
|
||
.iter()
|
||
.map(|layer| layer.name.clone())
|
||
.collect();
|
||
self.push_undo_snapshot(i, "LAYERSTATE RESTORE");
|
||
let restored = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.restore_layer_state(&name)
|
||
.unwrap_or(0);
|
||
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<String> = 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) {
|
||
if self.tabs.iter().any(|t| t.dirty) {
|
||
self.pending_close = Some(super::PendingClose::Quit);
|
||
return self.open_unsaved_dialog_window();
|
||
}
|
||
return self.exit_app();
|
||
}
|
||
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.
|
||
let anchor = panel.selected.unwrap_or(idx);
|
||
let (lo, hi) = (anchor.min(idx), anchor.max(idx));
|
||
panel.selected_multi = (lo..=hi).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];
|
||
if let Some(layer) = self.tabs[i].layers.layers.get(idx) {
|
||
self.tabs[i].layers.edit_buf = layer.name.clone();
|
||
}
|
||
self.tabs[i].layers.editing = Some(idx);
|
||
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(),
|
||
|
||
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 == Some(idx) {
|
||
panel.color_picker_row = None;
|
||
panel.color_full_palette = false;
|
||
} else {
|
||
panel.color_picker_row = Some(idx);
|
||
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) => {
|
||
let i = self.active_tab;
|
||
// Apply to every selected layer (multi-select), not just one.
|
||
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 = None;
|
||
dl.book_name = None;
|
||
}
|
||
}
|
||
for pl in self.tabs[i].layers.layers.iter_mut() {
|
||
if names.contains(&pl.name) {
|
||
pl.color = color;
|
||
pl.color_name = None;
|
||
pl.book_name = None;
|
||
}
|
||
}
|
||
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()
|
||
}
|
||
|
||
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::<i32>() {
|
||
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);
|
||
for name in &targets {
|
||
if let Some(layer) = self.tabs[i].scene.document.layers.get_mut(name) {
|
||
layer.transparency =
|
||
codec::types::Transparency::from_percent(v as f64 / 100.0);
|
||
}
|
||
if let Some(pl) = self.tabs[i]
|
||
.layers
|
||
.layers
|
||
.iter_mut()
|
||
.find(|l| &l.name == name)
|
||
{
|
||
pl.transparency = v;
|
||
}
|
||
}
|
||
if !targets.is_empty() {
|
||
self.tabs[i].scene.invalidate_layer_dependencies(&targets);
|
||
self.tabs[i].dirty = true;
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
// ── Cursor / viewport messages ─────────────────────────────────
|
||
Message::CursorMoved(p, viewport) => self.on_cursor_moved(p, viewport),
|
||
|
||
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_pick_target.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::ViewportLeftPress => {
|
||
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 => {
|
||
let i = self.active_tab;
|
||
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.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 => {
|
||
self.show_properties ^= true;
|
||
self.ribbon.set_properties(self.show_properties);
|
||
Task::none()
|
||
}
|
||
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::<f32>() {
|
||
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;
|
||
if let Some(entity) = self.anno_object_scale_target {
|
||
if self.tabs[i].scene.is_layer_locked(entity) {
|
||
return Task::none();
|
||
}
|
||
if let Some(sh) = self.tabs[i].scene.scale_handle_ensuring(&name) {
|
||
self.push_undo_snapshot(i, "OBJECTSCALE");
|
||
let doc = &mut self.tabs[i].scene.document;
|
||
let is_member =
|
||
crate::scene::annotative::object_scale_memberships(doc, entity)
|
||
.iter()
|
||
.any(|(_, h)| *h == sh);
|
||
if is_member {
|
||
crate::scene::annotative::remove_annotation_context_for_scale(
|
||
doc, entity, sh,
|
||
);
|
||
} else {
|
||
crate::scene::annotative::create_annotation_context(doc, entity, sh);
|
||
}
|
||
self.tabs[i].dirty = true;
|
||
self.invalidate_property_targets(i, &[entity]);
|
||
self.refresh_properties();
|
||
}
|
||
}
|
||
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;
|
||
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::<f64>() {
|
||
header.angle_base = degrees.to_radians();
|
||
}
|
||
header.insertion_units = state.insertion_units;
|
||
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;
|
||
state.error_message = None;
|
||
state.confirm_redefine = None;
|
||
}
|
||
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!("Block name cannot be empty.").into_owned());
|
||
return Task::none();
|
||
}
|
||
if let Some(_ch) = state.invalid_name_char() {
|
||
state.error_message = Some(
|
||
crate::t!("Block name cannot contain: \\ / : * ? \" < > | = `").into_owned(),
|
||
);
|
||
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;
|
||
let document = self.tabs[i].scene.document_for_save();
|
||
let source_mode = state.source_mode;
|
||
let block_name = state.block_name.trim().to_string();
|
||
let handles = state.selected_handles.clone();
|
||
let base_point = state.parse_base_point();
|
||
let unit = state.unit;
|
||
let object_mode = state.object_mode;
|
||
|
||
if source_mode == WblockSourceMode::Objects {
|
||
match object_mode {
|
||
WblockObjectMode::Retain => {}
|
||
WblockObjectMode::Delete => {
|
||
self.push_undo_snapshot(i, "WBLOCK");
|
||
self.tabs[i].scene.erase_entities(&handles);
|
||
self.tabs[i].dirty = true;
|
||
self.tabs[i].scene.bump_geometry();
|
||
self.refresh_properties();
|
||
}
|
||
WblockObjectMode::Convert => {
|
||
let block_name_for_conv = path
|
||
.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("WBLOCK")
|
||
.to_string();
|
||
self.push_undo_snapshot(i, "WBLOCK");
|
||
let ucs = self.tabs[i].ucs_xform();
|
||
let world_to_block = ucs.to_ucs_transform_at(base_point);
|
||
let block_to_world = ucs.to_wcs_transform_at(base_point);
|
||
let options = crate::scene::CreateBlockOptions {
|
||
name: block_name_for_conv,
|
||
handles: handles.clone(),
|
||
base_point,
|
||
world_to_block,
|
||
block_to_world,
|
||
mode: crate::ui::window::block_definition::BlockObjectMode::Convert,
|
||
annotative: false,
|
||
match_orientation: false,
|
||
scale_uniformly: false,
|
||
allow_exploding: true,
|
||
unit,
|
||
description: String::new(),
|
||
hyperlink_url: String::new(),
|
||
hyperlink_desc: String::new(),
|
||
redefine: true,
|
||
};
|
||
let _ = self.tabs[i].scene.create_block_with_options(options);
|
||
self.tabs[i].dirty = true;
|
||
self.tabs[i].scene.bump_geometry();
|
||
self.refresh_properties();
|
||
}
|
||
}
|
||
}
|
||
|
||
let worker_path = path.clone();
|
||
let display_name = match source_mode {
|
||
WblockSourceMode::Block => block_name.clone(),
|
||
WblockSourceMode::EntireDrawing => "*".to_string(),
|
||
WblockSourceMode::Objects => "*".to_string(),
|
||
};
|
||
let worker_display = display_name.clone();
|
||
|
||
file::background_task(
|
||
crate::t!("WBLOCK save"),
|
||
move || {
|
||
let out_doc = match source_mode {
|
||
WblockSourceMode::Block => {
|
||
let mut d = crate::modules::insert::wblock::extract_block_to_doc(
|
||
&document,
|
||
&block_name,
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
d.header.insertion_units = unit;
|
||
d
|
||
}
|
||
WblockSourceMode::EntireDrawing => {
|
||
let mut d = document.clone();
|
||
d.header.insertion_units = unit;
|
||
d
|
||
}
|
||
WblockSourceMode::Objects => {
|
||
crate::modules::insert::wblock::extract_entities_to_doc_with_base(
|
||
&document,
|
||
&handles,
|
||
base_point,
|
||
unit,
|
||
)
|
||
.map_err(|e| e.to_string())?
|
||
}
|
||
};
|
||
crate::io::save(&out_doc, &worker_path).map_err(|e| e.to_string())
|
||
},
|
||
move |result| Message::WblockWriteFinished(worker_display, path, result),
|
||
)
|
||
}
|
||
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].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::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::<f64>();
|
||
let angle = self.auto_constrain_angle_input.trim().parse::<f64>();
|
||
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();
|
||
}
|
||
self.ribbon.select_dropdown_item(dropdown_id, cmd);
|
||
self.ribbon.activate_tool(cmd);
|
||
self.dispatch_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::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 => {
|
||
self.command_line
|
||
.push_error(crate::tr!("clipboard", "no-supported-content").as_ref());
|
||
Task::none()
|
||
}
|
||
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()
|
||
}
|
||
}
|
||
}
|
||
|
||
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.active_modal == Some(super::ModalKind::Layers) {
|
||
// Select every row in the Layer Manager (#236).
|
||
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;
|
||
}
|
||
if matches!(
|
||
state.operator,
|
||
crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt
|
||
) && !state.property.as_ref().is_some_and(|property| {
|
||
matches!(property.editor, crate::app::QSelectValueEditor::Number)
|
||
}) {
|
||
state.operator = crate::app::QSelectOp::Eq;
|
||
}
|
||
}
|
||
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;
|
||
if matches!(
|
||
state.operator,
|
||
crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt
|
||
) && !state.property.as_ref().is_some_and(|property| {
|
||
matches!(property.editor, crate::app::QSelectValueEditor::Number)
|
||
}) {
|
||
state.operator = crate::app::QSelectOp::Eq;
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
Message::QSelectSetProperty(p) => {
|
||
if let Some(state) = self.qselect.as_mut() {
|
||
state.property = p;
|
||
state.value.clear();
|
||
state.error = None;
|
||
if matches!(
|
||
state.operator,
|
||
crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt
|
||
) && !state.property.as_ref().is_some_and(|property| {
|
||
matches!(property.editor, crate::app::QSelectValueEditor::Number)
|
||
}) {
|
||
state.operator = crate::app::QSelectOp::Eq;
|
||
}
|
||
}
|
||
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::QSelectApply => {
|
||
let validation_error = self.qselect.as_ref().and_then(|state| {
|
||
let candidate_count = self.tabs[self.active_tab]
|
||
.scene
|
||
.qselect_candidate_count(state.scope);
|
||
if candidate_count == 0 {
|
||
Some(crate::t!("No objects are available in this scope.").into_owned())
|
||
} else if let Some(property) = state.property.as_ref() {
|
||
if matches!(state.operator, crate::app::QSelectOp::Any) {
|
||
None
|
||
} else if matches!(
|
||
state.operator,
|
||
crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt
|
||
) && !matches!(
|
||
property.editor,
|
||
crate::app::QSelectValueEditor::Number
|
||
) {
|
||
Some(
|
||
crate::t!("This operator requires a numeric property.")
|
||
.into_owned(),
|
||
)
|
||
} else {
|
||
match &property.editor {
|
||
crate::app::QSelectValueEditor::Number
|
||
if crate::entities::common::parse_f64(&state.value)
|
||
.is_none() =>
|
||
{
|
||
Some(crate::t!("Enter a valid number.").into_owned())
|
||
}
|
||
crate::app::QSelectValueEditor::Choice(_)
|
||
if state.value.is_empty() =>
|
||
{
|
||
Some(crate::t!("Choose a value.").into_owned())
|
||
}
|
||
crate::app::QSelectValueEditor::Text
|
||
| crate::app::QSelectValueEditor::Number
|
||
| crate::app::QSelectValueEditor::Choice(_) => None,
|
||
}
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
});
|
||
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 matched = self.tabs[i].scene.qselect(
|
||
state.scope,
|
||
state.type_filter.as_deref(),
|
||
state.property.as_ref().map(|p| p.field.as_str()),
|
||
state.operator,
|
||
&state.value,
|
||
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);
|
||
}
|
||
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) => {
|
||
let i = self.active_tab;
|
||
self.ribbon.close_dropdown();
|
||
let handles = self.property_target_handles(i);
|
||
if handles.is_empty() {
|
||
if self.has_property_selection(i) {
|
||
return Task::none();
|
||
}
|
||
// Persist into the tab's header (CELWEIGHT). #21.
|
||
self.tabs[i].scene.document.header.current_line_weight = lw.value();
|
||
self.tabs[i].dirty = true;
|
||
self.ribbon.active_lineweight = lw;
|
||
} else {
|
||
// Lineweight is baked into the cached wire geometry —
|
||
// re-tessellate so the change shows immediately (issue #231
|
||
// class).
|
||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||
if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) {
|
||
crate::scene::view::dispatch::apply_line_weight(entity, lw);
|
||
}
|
||
});
|
||
self.ribbon.active_lineweight = lw;
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
Message::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) => {
|
||
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.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_color(entity, color);
|
||
}
|
||
});
|
||
self.tabs[i].properties.color_picker_open = false;
|
||
Task::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.props_asym_scale.contains(&handle.value());
|
||
if checked {
|
||
app.props_asym_scale.insert(handle.value());
|
||
} else {
|
||
app.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 => {
|
||
if !self.dock_panel_visible(crate::ui::dock::PanelId::Properties) {
|
||
return Task::none();
|
||
}
|
||
crate::ui::properties::sync_active_field_task()
|
||
}
|
||
|
||
Message::PropSyncActive(focused) => {
|
||
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::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<codec::Handle> = 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<String> = 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_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::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::BgPickerOpen(target) => {
|
||
self.bg_picker = Some(target);
|
||
Task::none()
|
||
}
|
||
|
||
Message::BgPickerCancel => {
|
||
self.bg_picker = None;
|
||
Task::none()
|
||
}
|
||
|
||
Message::BgPickerSubmit(color) => {
|
||
// Hand the result to the same handler the typed hex field
|
||
// uses, so the wheel and the field cannot drift apart on
|
||
// validation, persistence or the MatchTheme fallback.
|
||
let hex = crate::app::config::rgb_to_hex([
|
||
(color.r * 255.0).round() as u8,
|
||
(color.g * 255.0).round() as u8,
|
||
(color.b * 255.0).round() as u8,
|
||
]);
|
||
match self.bg_picker.take() {
|
||
Some(crate::app::BgTarget::Model) => {
|
||
Task::done(Message::ModelSpaceBgChanged(hex))
|
||
}
|
||
Some(crate::app::BgTarget::Paper) => {
|
||
Task::done(Message::PaperSpaceBgChanged(hex))
|
||
}
|
||
Some(crate::app::BgTarget::Desk) => {
|
||
Task::done(Message::DeskSpaceBgChanged(hex))
|
||
}
|
||
None => 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::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::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::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::<i32>() {
|
||
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::<f32>() {
|
||
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)
|
||
}
|
||
|
||
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();
|
||
}
|
||
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() };
|
||
}
|
||
// 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 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; rides ModalDragMove.
|
||
self.layer_col_dragging = true;
|
||
self.modal_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.layer_col_dragging {
|
||
self.layer_name_col_w = (self.layer_name_col_w + dx).clamp(60.0, 640.0);
|
||
} else 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.layer_col_dragging {
|
||
self.modal_drag_last = Some(p);
|
||
}
|
||
Task::none()
|
||
}
|
||
Message::ModalDragRelease => {
|
||
self.modal_dragging = false;
|
||
self.modal_resizing = false;
|
||
self.layer_col_dragging = 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<String> = 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::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(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::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_pdf_path_owned(stem, parent)
|
||
})
|
||
.map(Message::PrintAllPdfPath)
|
||
}
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
Task::perform(
|
||
crate::io::pdf_export::pick_pdf_path_async(stem),
|
||
Message::PrintAllPdfPath,
|
||
)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let _ = stem;
|
||
self.command_line.push_error(
|
||
crate::t!("PDF export is not available in the web version.").as_ref(),
|
||
);
|
||
Task::none()
|
||
}
|
||
}
|
||
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());
|
||
#[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_pdf_path_owned(stem, parent)
|
||
})
|
||
.map(Message::PlotExportPath)
|
||
}
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
Task::perform(
|
||
crate::io::pdf_export::pick_pdf_path_async(stem),
|
||
Message::PlotExportPath,
|
||
)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
Task::perform(
|
||
crate::io::pdf_export::pick_pdf_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(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_pdf_path_owned(stem, parent)
|
||
})
|
||
.map(Message::PlotWindowExportPath)
|
||
}
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
Task::perform(
|
||
crate::io::pdf_export::pick_pdf_path_async(stem),
|
||
Message::PlotWindowExportPath,
|
||
)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
Task::perform(
|
||
crate::io::pdf_export::pick_pdf_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);
|
||
Task::none()
|
||
}
|
||
Message::TextStyleDialogCopy => {
|
||
self.style_copy(crate::app::StyleKind::Text);
|
||
Task::none()
|
||
}
|
||
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::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);
|
||
Task::none()
|
||
}
|
||
Message::TableStyleDialogCopy => {
|
||
self.style_copy(crate::app::StyleKind::Table);
|
||
Task::none()
|
||
}
|
||
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);
|
||
Task::none()
|
||
}
|
||
Message::MlStyleDialogCopy => {
|
||
self.style_copy(crate::app::StyleKind::MLine);
|
||
Task::none()
|
||
}
|
||
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);
|
||
Task::none()
|
||
}
|
||
Message::MLeaderStyleDialogCopy => {
|
||
self.style_copy(crate::app::StyleKind::MLeader);
|
||
Task::none()
|
||
}
|
||
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);
|
||
Task::none()
|
||
}
|
||
Message::DimStyleDialogCopy => {
|
||
self.style_copy(crate::app::StyleKind::Dim);
|
||
Task::none()
|
||
}
|
||
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::<f64>().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::<i16>()
|
||
.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::<i16>()
|
||
.unwrap_or(0);
|
||
self.apply_ds_edit(field, (current ^ bit).to_string());
|
||
Task::none()
|
||
}
|
||
Message::DsCenterMarkMode(mode) => {
|
||
let size = self.ds_dimcen.trim().parse::<f64>().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) => {
|
||
self.color_pick_target = Some((target, color));
|
||
|
||
// Always open the shared CAD colour picker on the indexed ACI page.
|
||
self.color_picker_tab = crate::app::ColorPickerTab::Index;
|
||
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::ColorPickerTabChanged(tab) => {
|
||
self.color_picker_tab = tab;
|
||
Task::none()
|
||
}
|
||
Message::ColorPickerColorChanged(color) => {
|
||
if let Some((_, current)) = self.color_pick_target.as_mut() {
|
||
*current = color;
|
||
}
|
||
Task::none()
|
||
}
|
||
Message::CloseColorPicker => {
|
||
self.color_pick_target = None;
|
||
Task::none()
|
||
}
|
||
|
||
Message::ColorWindowPick(color) => {
|
||
self.note_recent_color(color);
|
||
self.on_color_window_pick(color)
|
||
}
|
||
Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value),
|
||
}
|
||
}
|
||
|
||
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.
|
||
if let Some(pos) = self.recent_colors.iter().position(|c| c == &color) {
|
||
self.recent_colors.remove(pos);
|
||
}
|
||
|
||
self.recent_colors.insert(0, color);
|
||
self.recent_colors.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::<f64>().ok();
|
||
let drawing = self.scale_manager_drawing_buf.trim().parse::<f64>().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<f64> = self.ts_hmargin.trim().parse().ok();
|
||
let v: Option<f64> = 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<String> = 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 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"));
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|