Files
OneCAD-Draft/src/app/update/mod.rs
T
Hakan SevenandClaude Opus 5.5 7a8ec02003 feat(options): command line text colour and a fonts folder
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]>
2026-10-07 08:50:12 +03:00

11270 lines
492 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, &current);
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,
&current,
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 != &current) {
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", &lt);
}
});
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(&current))
.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
}
}