use super::{Message, OpenCADStudio};
use crate::scene::VIEWCUBE_DRAW_PX;
use crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor;
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::DynTabNext
            | Message::MTextCaretMove(_)
            | Message::DeleteSelected
            | Message::ToggleSnapEnabled
            | Message::ToggleGrid
            | Message::ToggleOrtho
            | Message::ToggleGridSnap
            | Message::TogglePolar
            | Message::ToggleOTrack
            | Message::ToggleDynInput
            | Message::TabNew
            | Message::OpenFile
            | Message::SaveFile
            | Message::SaveAs
            | Message::Undo
            | Message::Redo
    )
}

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")
    }
}

mod command;
mod dialog;
mod dynamic;
mod file;
mod style;
mod util;
mod viewport;

impl OpenCADStudio {
    /// 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.
    fn close_active_modal(&mut self) {
        use super::ModalKind::*;
        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();
        }
        #[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;
            }
            // 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();
            }
            _ => {}
        }
        // 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.modal_offset = iced::Vector::ZERO;
        self.modal_resize = iced::Vector::ZERO;
        self.modal_drag_last = None;
        self.modal_dragging = false;
    }

    pub fn update(&mut self, msg: Message) -> Task<Message> {
        // 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) {
                return self.update(Message::CloseModal);
            }
            if is_modal_blocked_key_msg(&msg) {
                return Task::none();
            }
        }
        let task = self.update_inner(msg);
        // 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.
        let prompt = self.tabs[self.active_tab]
            .active_cmd
            .as_ref()
            .map(|c| c.prompt());
        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();
        // 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).
        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.snapper.clear_tracking();
            self.otrack_active = None;
        }
        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).
    fn reset_tracking_after_point(&mut self) {
        self.snapper.clear_tracking();
        self.otrack_active = None;
    }

    fn update_inner(&mut self, msg: Message) -> Task<Message> {
        match msg {
            // Web: a drawing referenced a script whose Noto subset isn't loaded
            // yet (recorded during text tessellation). Kick off one fetch per
            // pending script; the result comes back as `WebFontLoaded`. (#141)
            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. Store it, drop the stale fallback
            // glyph cache (entries that resolved to nothing while it loaded),
            // and re-tessellate so the text appears. (#141)
            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.bump_geometry();
                        }
                    }
                    Err(e) => {
                        crate::scene::text::web_font::insert(script, None);
                        self.command_line
                            .push_error(&format!("Font load failed ({script:?}): {e}"));
                    }
                }
                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);
                Task::none()
            }

            Message::StartSectionSelect(section) => {
                self.start_section = section;
                self.save_config();
                Task::none()
            }

            Message::TogglePropertiesBar => {
                self.props_expanded = !self.props_expanded;
                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) => {
                // 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.
                match std::fs::metadata(&path) {
                    Ok(m) => self.update(Message::OpenPathPicked(Some((path, m.len())))),
                    Err(_) => {
                        self.command_line.push_error(&format!(
                            "Recent file no longer exists: {}",
                            path.display()
                        ));
                        Task::none()
                    }
                }
            }

            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(),
                };
                // Already open → go to that tab rather than load a second copy
                // of the same drawing. Checked before the queue: switching is
                // instant and needs no load slot.
                if let Some(idx) = self.tab_showing(&path) {
                    return Task::batch([raise, self.update(Message::TabSwitch(idx))]);
                }
                if self.opening.is_some() {
                    self.pending_opens.push_back(path);
                    raise
                } 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(&format!("Snap override: {label} (next pick only)."));
                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 !matches!(ext.as_str(), "dwg" | "dxf" | "bak" | "sv$") {
                    self.command_line.push_error(&format!(
                        "Unsupported file type: {}",
                        path.display()
                    ));
                    return Task::none();
                }
                // Already open → switch to its tab; a load in progress →
                // queue behind it (multi-file drops arrive one event each).
                if let Some(idx) = self.tab_showing(&path) {
                    return self.update(Message::TabSwitch(idx));
                }
                if self.opening.is_some() {
                    self.pending_opens.push_back(path);
                    Task::none()
                } 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,
                ));
                self.opening = Some(super::OpenProgress {
                    name: name.clone(),
                    size_bytes,
                    state: progress.clone(),
                    started: Instant::now(),
                    #[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(&format!("Opening \"{name}\" ({size_label})…"));
                let model_bg = self.default_bg_color.unwrap_or([
                    33.0 / 255.0,
                    40.0 / 255.0,
                    48.0 / 255.0,
                    1.0,
                ]);
                Task::perform(
                    crate::io::open_path_with_phase(path, progress, model_bg),
                    Message::FileOpened,
                )
            }

            Message::OpenCancel => {
                if let Some(p) = self.opening.take() {
                    self.command_line
                        .push_info(&format!("Open cancelled: \"{}\"", p.name));
                }
                self.drain_pending_open()
            }

            Message::FileOpened(Ok((name, path, doc, caches))) => {
                self.on_file_opened(name, path, doc, caches)
            }

            Message::FileOpened(Err(e)) => {
                // If the user cancelled, the overlay was already cleared and
                // we suppress the noise.
                let was_open = self.opening.take().is_some();
                if was_open && e != "Cancelled" {
                    self.command_line.push_error(&format!("Open failed: {e}"));
                }
                // A drawing that fails to parse must not strand the ones queued
                // behind it.
                self.drain_pending_open()
            }

            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(&format!("IMAGE  \"{short}\": {pw}×{ph} px"));
                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(&format!("IMAGE: {e}"));
                }
                Task::none()
            }

            Message::XAttachPick => Task::perform(
                async {
                    let handle = crate::sys::file_dialog()
                        .set_title("Select External Reference File")
                        .add_filter("CAD Files", &["dwg", "dxf", "bak", "DWG", "DXF", "BAK"])
                        .add_filter("DWG Files", &["dwg", "DWG"])
                        .add_filter("DXF Files", &["dxf", "DXF"])
                        .add_filter("Backup Files", &["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)) => {
                use crate::command::CadCommand;
                use crate::modules::insert::xattach::XAttachCommand;
                let path_str = path.to_string_lossy().into_owned();
                let cmd = XAttachCommand::with_path(path_str);
                let i = self.active_tab;
                self.command_line.push_info(&cmd.prompt());
                self.tabs[i].active_cmd = Some(Box::new(cmd));
                Task::none()
            }

            Message::XAttachPickResult(Err(e)) => {
                if e != "Cancelled" {
                    self.command_line.push_error(&format!("XATTACH: {e}"));
                }
                Task::none()
            }

            Message::WblockSave(block_name) => {
                let name = block_name.clone();
                Task::perform(
                    async move {
                        let path = crate::sys::file_dialog()
                            .set_title("Save Block As")
                            .set_file_name("block.dwg")
                            .add_filter("DWG Files", &["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(&format!(
                        "WBLOCK  Saved \"{block_name}\" → \"{}\"",
                        path.display()
                    )),
                    Err(error) => self
                        .command_line
                        .push_error(&format!("WBLOCK save failed: {error}")),
                }
                Task::none()
            }

            Message::DataExtractionSave(csv) => {
                let csv_clone = csv.clone();
                Task::perform(
                    async move {
                        let path = crate::sys::file_dialog()
                            .set_title("Save Data Extraction")
                            .set_file_name("extraction.csv")
                            .add_filter("CSV", &["csv"])
                            .add_filter("All Files", &["*"])
                            .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(&format!("DATAEXTRACTION  {rows} rows → \"{fname}\""));
                    }
                    Err(e) => self
                        .command_line
                        .push_error(&format!("DATAEXTRACTION: write failed: {e}")),
                }
                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("STLOUT: no 3D mesh data in this drawing.");
                    return Task::none();
                }
                Task::perform(
                    async {
                        crate::sys::file_dialog()
                            .set_title("Export STL")
                            .set_file_name("export.stl")
                            .add_filter("STL Files", &["stl"])
                            .add_filter("All Files", &["*"])
                            .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(&format!("STLOUT: exported to \"{}\"", path.display())),
                    Err(error) => self.command_line.push_error(&format!("STLOUT: {error}")),
                }
                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("STEPOUT: no 3D mesh data in this drawing.");
                    return Task::none();
                }
                Task::perform(
                    async {
                        crate::sys::file_dialog()
                            .set_title("Export STEP AP203")
                            .set_file_name("export.step")
                            .add_filter("STEP Files", &["step", "stp"])
                            .add_filter("All Files", &["*"])
                            .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(&format!("STEPOUT: exported to \"{}\"", path.display())),
                    Err(error) => self.command_line.push_error(&format!("STEPOUT: {error}")),
                }
                Task::none()
            }

            // ── OBJ import ────────────────────────────────────────────────
            Message::ObjImport => Task::perform(
                async {
                    crate::sys::file_dialog()
                        .set_title("Import OBJ Mesh")
                        .add_filter("Wavefront OBJ", &["obj", "OBJ"])
                        .add_filter("All Files", &["*"])
                        .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(&format!("IMPORTOBJ: {error}")),
                    Ok(mut mesh) => {
                        let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
                            self.command_line
                                .push_info("IMPORTOBJ: target drawing was closed.");
                            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(&format!(
                                "IMPORTOBJ: imported \"{file_stem}\" as mesh."
                            ));
                        }
                    }
                }
                Task::none()
            }

            Message::SaveFile => self.on_save_file(),

            Message::SaveAs => {
                if self.read_only {
                    self.command_line
                        .push_error("Read-only session (--read-only): saving is disabled.");
                    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.clear();
                crate::io::linetypes::populate_document(&mut self.tabs[i].scene.document);
                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("Scene cleared. Standard linetypes loaded.");
                self.tabs[i].current_path = None;
                self.tabs[i].dirty = true;
                self.sync_ribbon_layers();
                Task::none()
            }

            Message::SetWireframe(w) => {
                // Back-compat shim: forward to the new render-mode path so
                // the ribbon button + WIREFRAME / SOLID command line still
                // work without duplicating the rendering plumbing.
                let mode = if w {
                    acadrust::entities::ViewportRenderMode::Wireframe2D
                } else {
                    acadrust::entities::ViewportRenderMode::FlatShaded
                };
                Task::done(Message::SetRenderMode(mode))
            }

            Message::SetRenderMode(mode) => self.on_set_render_mode(mode),

            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.camera.borrow_mut().projection = proj;
                self.tabs[i].scene.camera_generation += 1;
                self.ribbon.set_ortho(ortho);
                self.command_line.push_output(if ortho {
                    "Projection: Orthographic"
                } else {
                    "Projection: Perspective"
                });
                Task::none()
            }

            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 } => {
                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(&format!("No plugin handled: {command}"));
                    }
                }
                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_bg_default(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);
                Task::none()
            }

            Message::TabSwitch(idx) => {
                self.doc_tab_context_menu = None;
                self.layout_list_open = false;
                self.layout_context_menu = None;
                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.active_modal == Some(super::ModalKind::LayoutManager)
                    {
                        self.close_active_modal();
                    }
                    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.bump_geometry();
                    }
                }
                Task::none()
            }

            Message::TabClose(idx) => {
                self.doc_tab_context_menu = None;
                self.on_tab_close(idx)
            }

            Message::DocTabContextMenu(idx) => {
                if self.tabs.get(idx).is_some_and(|tab| !tab.is_start) {
                    self.layout_context_menu = None;
                    self.doc_tab_context_menu = Some(idx);
                }
                Task::none()
            }

            Message::DocTabContextMenuClose => {
                self.doc_tab_context_menu = None;
                Task::none()
            }

            Message::DocTabSaveAll => {
                self.doc_tab_context_menu = None;
                self.dispatch_command("SAVEALL")
            }

            Message::DocTabCloseAll => {
                self.doc_tab_context_menu = None;
                let ids = self
                    .tabs
                    .iter()
                    .filter(|tab| !tab.is_start)
                    .map(|tab| tab.id)
                    .collect();
                self.begin_tab_close_queue(ids)
            }

            Message::DocTabCloseOthers(idx) => {
                self.doc_tab_context_menu = None;
                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) => {
                self.doc_tab_context_menu = None;
                #[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("Save the drawing before copying its file path.");
                        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(&format!("Copied path: {}", full_path.display()));
                    return iced::clipboard::write(full_path.to_string_lossy().into_owned());
                }
                #[cfg(target_arch = "wasm32")]
                {
                    let _ = idx;
                    self.command_line
                        .push_error("Full file paths are unavailable in the web application.");
                    Task::none()
                }
            }

            Message::DocTabOpenFileLocation(idx) => {
                self.doc_tab_context_menu = None;
                #[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("Save the drawing before opening its file location.");
                        return Task::none();
                    };
                    match crate::sys::reveal_in_file_manager(&path) {
                        Ok(()) => self
                            .command_line
                            .push_output(&format!("Opened file location: {}", path.display())),
                        Err(error) => self
                            .command_line
                            .push_error(&format!("Could not open file location: {error}")),
                    }
                    Task::none()
                }
                #[cfg(target_arch = "wasm32")]
                {
                    let _ = idx;
                    self.command_line
                        .push_error("File locations are unavailable in the web application.");
                    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.
                let s = 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.
                if !self.command_line.literal_spaces && !s.starts_with('>') && s.contains(' ') {
                    self.command_line.input = s;
                    return self.update(Message::CommandSubmit);
                }
                self.command_line.input = s;
                // Typing invalidates the previous arrow-key cursor —
                // the matches list has likely changed.
                self.command_line.autocomplete_cursor = None;
                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 => {
                // 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();
                }
                // While autocomplete is showing suggestions, ↑ walks up
                // that list. Otherwise it falls back to recall history.
                let i = self.active_tab;
                if self.tabs[i].active_cmd.is_none() && self.command_line.autocomplete_prev() {
                    return Task::none();
                }
                self.command_line.history_prev();
                Task::none()
            }

            Message::CommandHistoryNext => {
                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.tabs[i].active_cmd.is_none() && self.command_line.autocomplete_next() {
                    return Task::none();
                }
                self.command_line.history_next();
                Task::none()
            }

            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();
                // On open, snapshot the current log into the read-only editor
                // buffer so it can be drag-selected across lines and copied.
                if self.command_line.history_open {
                    use iced::widget::text_editor::{Action, Motion};
                    self.history_content = iced::widget::text_editor::Content::with_text(
                        &self.command_line.history_plain_text(),
                    );
                    // Scroll to the newest line (bottom), the most useful when
                    // opening the log to grab a recent error.
                    self.history_content.perform(Action::Move(Motion::DocumentEnd));
                }
                Task::none()
            }

            Message::CommandHistoryCopy => {
                let text = self.command_line.history_plain_text();
                if text.is_empty() {
                    Task::none()
                } else {
                    iced::clipboard::write(text)
                }
            }

            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)
                }
            }

            Message::PerfClear => {
                crate::perf::clear();
                Task::none()
            }

            Message::CommandHistoryEdit(action) => {
                // Read-only: drop edits, keep selection / cursor / scroll so
                // the user can still highlight and Ctrl+C the log.
                if !action.is_edit() {
                    self.history_content.perform(action);
                }
                Task::none()
            }

            Message::CommandSuggestionPick(cmd) => {
                self.command_line.input.clear();
                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);
                Task::none()
            }

            Message::CommandSubmit => self.on_command_submit(),

            Message::CommandSpace => {
                // Space is a literal space inside the MText preview; otherwise
                // it finalises the active command like Enter.
                if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
                    self.mtext_type(" ");
                    return Task::none();
                }
                // A leading `>` (or the persistent `>` toggle) puts the command
                // line in "literal space" mode so the user can type arguments
                // that contain spaces; otherwise Space works like Enter. 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();
                }
                return self.update(Message::CommandFinalize);
            }
            Message::CommandFinalize => self.on_command_finalize(),

            Message::CommandEscape => 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().context_menu = 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::ToggleLayers => {
                if self.active_modal == Some(super::ModalKind::Layers) {
                    self.ribbon.deactivate_tool_if("LAYERS");
                    self.active_modal = None;
                } else {
                    self.sync_ribbon_layers();
                    self.active_modal = Some(super::ModalKind::Layers);
                }
                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(idx) => {
                let i = self.active_tab;
                // 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_dependencies(&targets);
                        self.tabs[i].dirty = true;
                        self.commit_layer_undo(i, undo);
                        self.command_line.push_output(&format!(
                            "{} layer(s) turned {}",
                            targets.len(),
                            if on { "on" } else { "off" }
                        ));
                        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(idx) => {
                let i = self.active_tab;
                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(&format!(
                            "{} layer(s) {}",
                            targets.len(),
                            if locked { "locked" } else { "unlocked" }
                        ));
                        self.sync_ribbon_layers();
                    }
                }
                Task::none()
            }

            Message::LayerToggleFreeze(idx) => {
                let i = self.active_tab;
                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_dependencies(&targets);
                        self.tabs[i].dirty = true;
                        self.commit_layer_undo(i, undo);
                        self.command_line.push_output(&format!(
                            "{} layer(s) {}",
                            targets.len(),
                            if frozen { "frozen" } else { "thawed" }
                        ));
                        self.sync_ribbon_layers();
                    }
                }
                Task::none()
            }

            Message::LayerToggleVpFreeze(layer_idx, vp_col_idx) => {
                self.on_layer_toggle_vp_freeze(layer_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(idx) => {
                let i = self.active_tab;
                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(idx) => {
                let i = self.active_tab;
                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(idx) => {
                let i = self.active_tab;
                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(aci) => {
                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);
                    use crate::ui::window::layers::iced_color_from_acad;
                    let new_color = iced_color_from_acad(&AcadColor::Index(aci));
                    for name in &names {
                        if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) {
                            dl.color = AcadColor::Index(aci);
                        }
                    }
                    for pl in self.tabs[i].layers.layers.iter_mut() {
                        if names.contains(&pl.name) {
                            pl.color = new_color;
                        }
                    }
                    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(idx, s) => {
                let i = self.active_tab;
                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(pl) =
                            self.tabs[i].layers.layers.iter_mut().find(|l| &l.name == name)
                        {
                            pl.transparency = v;
                        }
                    }
                }
                Task::none()
            }

            // ── Cursor / viewport messages ─────────────────────────────────
            Message::CursorMoved(p) => self.on_cursor_moved(p),

            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) => {
                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()
                        .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 fresh press ends any stale (un-dropped) pane move.
                self.pane_move_from = None;
                self.focus_model_pane(idx);
                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 => 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().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.last_move_pos else {
                    return Task::none();
                };
                sel.context_menu = None;
                sel.right_down = true;
                sel.right_press_pos = Some(p);
                sel.right_press_time = Some(iced::time::Instant::now());
                sel.right_last_pos = Some(p);
                sel.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.last_move_pos else {
                    return Task::none();
                };
                if !sel.right_down {
                    return Task::none();
                }
                let was_click = !sel.right_dragging;
                sel.right_down = false;
                sel.right_press_pos = None;
                sel.right_press_time = None;
                sel.right_last_pos = None;
                sel.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.right_click_entered = false;
                    drop(sel);
                    return self.update(Message::CommandFinalize);
                }
                // A right-click (no orbit). While a command is active the first
                // right-click acts as Enter (commit / close); a second
                // consecutive right-click opens the context menu instead. When
                // idle it always opens the menu. (Right-drag, handled above,
                // always orbits.) Any other interaction — a left-click pick or a
                // new command — resets the cycle so the next right-click is Enter.
                if self.tabs[i].active_cmd.is_some() && !sel.right_click_entered {
                    sel.right_click_entered = true;
                    drop(sel);
                    return self.update(Message::CommandFinalize);
                }
                sel.right_click_entered = false;
                sel.context_menu = Some(click_pos);
                sel.draworder_submenu = false;
                Task::none()
            }

            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.middle_down = false;
                sel.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;
                Task::none()
            }

            Message::ViewportScroll(delta) => self.on_viewport_scroll(delta),

            Message::ViewportClick => self.on_viewport_click(),

            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;
                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("View: Home");
                Task::none()
            }

            Message::ViewCubeRoll(cw) => {
                let i = self.active_tab;
                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;
                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;
                if name.is_empty() || name == "WCS" {
                    self.tabs[i].active_ucs = None;
                    self.command_line.push_output("UCS: World");
                } 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(&format!("UCS: {}", name));
                }
                self.tabs[i].sync_ucs_to_scene();
                self.tabs[i].scene.camera_generation += 1;
                self.tabs[i].dirty = true;
                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()
                    .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().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().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::ToggleGridSnap => {
                self.snapper.toggle_grid_snap();
                self.sync_vport_display(self.active_tab);
                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(&format!("COORDS = {mode} ({label})"));
                }
                Task::none()
            }
            Message::TogglePolar => {
                self.polar_mode ^= true;
                if self.polar_mode {
                    self.ortho_mode = false;
                }
                Task::none()
            }
            Message::ToggleDynInput => {
                self.dyn_input ^= true;
                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();
                }
                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 => {
                self.polar_popup_open ^= true;
                if self.polar_popup_open {
                    // Start the custom field empty each time; picking a preset or
                    // typing a value is what actually enables polar tracking.
                    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;
                if let Some(tab) = self.tabs.get_mut(self.active_tab) {
                    tab.scene.annotation_scale = scale;
                    util::sync_annotation_scale_header(&mut tab.scene);
                    tab.scene.bump_geometry();
                }
                Task::none()
            }
            Message::SetViewportScale(scale) => {
                self.scale_popup_open = false;
                if let Some(tab) = self.tabs.get_mut(self.active_tab) {
                    tab.scene.set_viewport_scale(scale);
                }
                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 {
                    // Only object types that carry a per-object context.
                    let ok = matches!(
                        self.tabs[i].scene.document.get_entity(handles[0]),
                        Some(
                            acadrust::EntityType::Text(_)
                                | acadrust::EntityType::MText(_)
                                | acadrust::EntityType::Insert(_)
                        )
                    );
                    if ok {
                        self.anno_object_scale_target = Some(handles[0]);
                        self.active_modal = Some(crate::app::ModalKind::AnnoObjectScale);
                    } else {
                        self.command_line.push_info(
                            "OBJECTSCALE applies to a single Text, MText or block reference.",
                        );
                    }
                } else {
                    self.command_line
                        .push_info("Select one object first, then run OBJECTSCALE.");
                }
                Task::none()
            }
            Message::AnnoObjectScaleToggle(name) => {
                let i = self.active_tab;
                if let Some(entity) = self.anno_object_scale_target {
                    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();
                if let Some((_, anno, _)) = self
                    .tabs[i]
                    .scene
                    .scale_list()
                    .into_iter()
                    .find(|(n, _, _)| n.eq_ignore_ascii_case(&sel))
                {
                    self.tabs[i].scene.annotation_scale = anno;
                    self.tabs[i].scene.document.header.current_annotation_scale = sel.clone();
                    if let Some((p, d)) = self.tabs[i].scene.scale_paper_drawing(&sel) {
                        if d != 0.0 {
                            self.tabs[i].scene.document.header.annotation_scale_value = p / d;
                        }
                    }
                    self.tabs[i].scene.bump_geometry();
                }
                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;
                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).
                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();
                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 {
                    f.insert(t.to_string());
                }
                Task::none()
            }
            Message::ToggleUnitsPopup => {
                self.units_popup_open ^= true;
                Task::none()
            }
            Message::CloseUnitsPopup => {
                self.units_popup_open = false;
                Task::none()
            }
            Message::SetDrawingUnits(code) => {
                self.units_popup_open = false;
                let i = self.active_tab;
                self.tabs[i].scene.document.header.insertion_units = code;
                self.tabs[i].dirty = true;
                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 => {
                self.snap_popup_open ^= true;
                Task::none()
            }
            Message::CloseSnapPopup => {
                self.snap_popup_open = false;
                Task::none()
            }
            Message::SnapSelectAll => {
                self.snapper.enable_all();
                Task::none()
            }
            Message::SnapClearAll => {
                self.snapper.disable_all();
                Task::none()
            }

            // ── Ribbon dropdowns ──────────────────────────────────────────
            Message::ToggleRibbonDropdown(id) => {
                self.ribbon.toggle_dropdown(&id);
                Task::none()
            }
            Message::ToggleRibbonPanel(id) => {
                self.ribbon.toggle_collapsed_panel(&id);
                Task::none()
            }
            Message::CloseRibbonDropdown => {
                self.ribbon.close_dropdown();
                Task::none()
            }
            Message::DropdownSelectItem { dropdown_id, cmd } => {
                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().context_menu = 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) => {
                if let Some(ed) = self.mtext_editor.as_mut() {
                    ed.height = s;
                }
                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) => {
                if let Some(ed) = self.mtext_editor.as_mut() {
                    ed.oblique = s;
                }
                self.rebuild_mtext_preview();
                Task::none()
            }
            Message::MTextWidth(s) => {
                if let Some(ed) = self.mtext_editor.as_mut() {
                    ed.width = s;
                }
                self.rebuild_mtext_preview();
                Task::none()
            }
            Message::MTextCharSpace(s) => {
                if let Some(ed) = self.mtext_editor.as_mut() {
                    ed.char_space = s;
                }
                self.rebuild_mtext_preview();
                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;
                        }
                    }
                }
                Task::none()
            }
            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) => {
                self.mtext_caret_move(d);
                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 it falls through
            // to the entity paste command.
            Message::PasteShortcut => self.on_paste_shortcut(),

            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::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::DrawOrderSubmenuToggle => {
                let i = self.active_tab;
                let mut sel = self.tabs[i].scene.selection.borrow_mut();
                sel.draworder_submenu = !sel.draworder_submenu;
                Task::none()
            }

            Message::DrawOrderPickRef(above) => {
                let i = self.active_tab;
                self.tabs[i].scene.selection.borrow_mut().context_menu = None;
                let to_move: Vec<_> = self.tabs[i].scene.selected.iter().cloned().collect();
                if to_move.is_empty() {
                    self.command_line
                        .push_error("DRAWORDER: select entities first.");
                } else {
                    use crate::command::CadCommand;
                    let cmd = super::commands::DrawOrderRefCommand::new(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().context_menu = None;
                let added = self.tabs[i].scene.select_similar();
                self.command_line
                    .push_output(&format!("Select Similar: {} added.", added));
                self.refresh_properties();
                Task::none()
            }

            Message::InvertSelection => {
                let i = self.active_tab;
                self.tabs[i].scene.selection.borrow_mut().context_menu = None;
                let count = self.tabs[i].scene.invert_selection();
                self.command_line
                    .push_output(&format!("Invert Selection: {} object(s) selected.", count));
                self.refresh_properties();
                Task::none()
            }

            Message::QSelectOpen => self.on_qselect_open(),

            Message::QSelectClose => {
                self.qselect = None;
                Task::none()
            }

            Message::QSelectSetType(t) => {
                if let Some(state) = self.qselect.as_mut() {
                    // Drop the property when it no longer applies to the
                    // chosen type: type-specific fields like `start_x`
                    // would otherwise stay selected but never match.
                    let kept_property = state.property.clone().and_then(|p| {
                        let i = self.active_tab;
                        let props = self.tabs[i].scene.qselect_properties(t.as_deref());
                        if props.iter().any(|(f, _)| f == &p.field) {
                            Some(p)
                        } else {
                            None
                        }
                    });
                    state.type_filter = t;
                    state.property = kept_property;
                }
                Task::none()
            }

            Message::QSelectSetProperty(p) => {
                if let Some(state) = self.qselect.as_mut() {
                    state.property = p;
                }
                Task::none()
            }

            Message::QSelectSetOperator(op) => {
                if let Some(state) = self.qselect.as_mut() {
                    state.operator = op;
                }
                Task::none()
            }

            Message::QSelectSetValue(v) => {
                if let Some(state) = self.qselect.as_mut() {
                    state.value = v;
                }
                Task::none()
            }

            Message::QSelectSetAppend(b) => {
                if let Some(state) = self.qselect.as_mut() {
                    state.append = b;
                }
                Task::none()
            }

            Message::QSelectApply => {
                let Some(state) = self.qselect.take() else {
                    return Task::none();
                };
                let i = self.active_tab;
                let matched = self.tabs[i].scene.qselect(
                    state.type_filter.as_deref(),
                    state.property.as_ref().map(|p| p.field.as_str()),
                    state.operator,
                    &state.value,
                    state.append,
                );
                self.command_line
                    .push_output(&format!("QSELECT: {} object(s) selected.", matched));
                self.refresh_properties();
                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() {
                    // 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 {
                    self.push_undo_snapshot(i, "CHPROP");
                    for &handle in &handles {
                        if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
                            crate::scene::view::dispatch::apply_line_weight(entity, lw);
                        }
                    }
                    // Lineweight is baked into the cached wire geometry —
                    // re-tessellate so the change shows immediately (issue #231
                    // class).
                    self.invalidate_property_targets(i, &handles);
                    self.tabs[i].dirty = true;
                    self.ribbon.active_lineweight = lw;
                    self.refresh_properties();
                }
                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.push_undo_snapshot(i, "CHPROP");
                for &handle in &handles {
                    if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
                        crate::scene::view::dispatch::apply_common_prop(
                            entity, "layer", &layer,
                        );
                    }
                }
                self.invalidate_property_targets(i, &handles);
                self.tabs[i].dirty = true;
                self.refresh_properties();
                Task::none()
            }

            Message::PropColorChanged(color) => {
                let i = self.active_tab;
                let handles = self.property_target_handles(i);
                if handles.is_empty() {
                    self.tabs[i].properties.color_palette_open = false;
                    let task = self.on_ribbon_color_changed(color);
                    self.refresh_properties();
                    return task;
                }
                self.push_undo_snapshot(i, "CHPROP");
                for &handle in &handles {
                    if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
                        crate::scene::view::dispatch::apply_color(entity, color);
                    }
                }
                self.invalidate_property_targets(i, &handles);
                self.tabs[i].properties.color_picker_open = false;
                self.tabs[i].properties.color_palette_open = false;
                self.tabs[i].dirty = true;
                self.refresh_properties();
                Task::none()
            }

            Message::PropLwChanged(lw) => {
                let i = self.active_tab;
                let handles = self.property_target_handles(i);
                if handles.is_empty() {
                    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.push_undo_snapshot(i, "CHPROP");
                for &handle in &handles {
                    if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
                        crate::scene::view::dispatch::apply_line_weight(entity, lw);
                    }
                }
                self.invalidate_property_targets(i, &handles);
                self.tabs[i].dirty = true;
                self.refresh_properties();
                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.push_undo_snapshot(i, "CHPROP");
                for &handle in &handles {
                    if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
                        crate::scene::view::dispatch::apply_common_prop(
                            entity, "linetype", &lt,
                        );
                    }
                }
                self.invalidate_property_targets(i, &handles);
                self.tabs[i].dirty = true;
                self.refresh_properties();
                Task::none()
            }

            Message::PropHatchPatternChanged(name) => self.on_prop_hatch_pattern_changed(name),

            Message::PropBoolToggle(field) => {
                let i = self.active_tab;
                let handles = self.property_target_handles(i);
                if !handles.is_empty() {
                    self.push_undo_snapshot(i, "CHPROP");
                    for &handle in &handles {
                        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 = &self.tabs[i].scene.document;
                                let cur = match doc.get_entity(handle) {
                                    Some(acadrust::EntityType::MText(t)) => t.is_annotative,
                                    Some(acadrust::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 => continue,
                                };
                                crate::scene::annotative::set_entity_annotative(
                                    &mut self.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) =
                                        self.tabs[i].scene.current_annotation_scale_handle()
                                    {
                                        crate::scene::annotative::create_annotation_context(
                                            &mut self.tabs[i].scene.document,
                                            handle,
                                            sh,
                                        );
                                    }
                                }
                            }
                            "invisible" => {
                                if let Some(entity) =
                                    self.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 self.tabs[i].scene.document.get_entity(handle) {
                                    Some(acadrust::EntityType::Insert(ins)) => {
                                        Some((ins.x_scale(), ins.y_scale(), ins.z_scale()))
                                    }
                                    _ => None,
                                };
                                let Some((sx, sy, sz)) = scales else { continue };
                                let eq = (sx - sy).abs() < 1e-12 && (sx - sz).abs() < 1e-12;
                                let checked =
                                    eq && !self.props_asym_scale.contains(&handle.value());
                                if checked {
                                    self.props_asym_scale.insert(handle.value());
                                } else {
                                    self.props_asym_scale.remove(&handle.value());
                                    if let Some(acadrust::EntityType::Insert(ins)) =
                                        self.tabs[i].scene.document.get_entity_mut(handle)
                                    {
                                        ins.set_y_scale(sx);
                                        ins.set_z_scale(sx);
                                    }
                                }
                            }
                            _ => {
                                if let Some(entity) =
                                    self.tabs[i].scene.document.get_entity_mut(handle)
                                {
                                    crate::scene::view::dispatch::apply_geom_prop(
                                        entity, field, "toggle",
                                    );
                                }
                            }
                        }
                    }
                    self.invalidate_property_targets(i, &handles);
                    self.tabs[i].dirty = true;
                    self.refresh_properties();
                }
                Task::none()
            }

            Message::PropVertexStep(delta) => {
                let i = self.active_tab;
                let handles = self.property_target_handles(i);
                // Vertex navigation applies to a single selected polyline.
                let n = if handles.len() == 1 {
                    match self.tabs[i].scene.document.get_entity(handles[0]) {
                        Some(acadrust::EntityType::LwPolyline(p)) => p.vertices.len(),
                        Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(),
                        _ => 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.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(field.to_string(), 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::PropEditChoiceToggle => {
                let panel = &mut self.tabs[self.active_tab].properties;
                panel.edit_choice_open = !panel.edit_choice_open;
                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::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.color_palette_open = false;
                }
                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.push_undo_snapshot(i, "CHPROP");
                    for &handle in &handles {
                        match self.tabs[i].scene.document.get_entity_mut(handle) {
                            Some(acadrust::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(acadrust::EntityType::Hatch(h)) => {
                                crate::entities::hatch::set_background_color(h, &color);
                            }
                            _ => {}
                        }
                    }
                    self.invalidate_property_targets(i, &handles);
                    self.tabs[i].properties.bg_color_picker_open = false;
                    self.tabs[i].dirty = true;
                    self.refresh_properties();
                }
                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);
                // 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 field == "dim_line_color" {
                    let aci = color.approximate_index();
                    let targets: Vec<acadrust::Handle> = handles
                        .iter()
                        .copied()
                        .filter(|&h| {
                            matches!(
                                self.tabs[i].scene.document.get_entity(h),
                                Some(acadrust::EntityType::Leader(_))
                                    | Some(acadrust::EntityType::Dimension(_))
                            )
                        })
                        .collect();
                    if !targets.is_empty() {
                        self.push_undo_snapshot(i, "CHPROP");
                        for &handle in &targets {
                            crate::entities::dim_override::set(
                                &mut self.tabs[i].scene.document,
                                handle,
                                crate::entities::dim_override::DIMCLRD,
                                Some(acadrust::xdata::XDataValue::Integer16(aci)),
                            );
                        }
                        self.invalidate_property_targets(i, &targets);
                        self.tabs[i].properties.open_color_field = None;
                        self.tabs[i].dirty = true;
                        self.refresh_properties();
                    }
                    return Task::none();
                }
                if !handles.is_empty() {
                    self.push_undo_snapshot(i, "CHPROP");
                    let idx = if field == "gradient_color_2" { 1 } else { 0 };
                    for &handle in &handles {
                        if let Some(acadrust::EntityType::Hatch(h)) =
                            self.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(
                                    acadrust::entities::hatch::GradientColorEntry {
                                        value,
                                        color: acadrust::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.invalidate_property_targets(i, &handles);
                    self.tabs[i].properties.open_color_field = None;
                    self.tabs[i].dirty = true;
                    self.refresh_properties();
                }
                Task::none()
            }

            Message::PropColorPickerClose => {
                let i = self.active_tab;
                self.tabs[i].properties.color_picker_open = false;
                self.tabs[i].properties.color_palette_open = false;
                Task::none()
            }

            Message::PropColorPaletteToggle => {
                self.tabs[self.active_tab].properties.color_palette_open =
                    !self.tabs[self.active_tab].properties.color_palette_open;
                Task::none()
            }

            Message::LayoutSwitch(name) => {
                self.layout_list_open = false;
                self.on_layout_switch(name)
            }

            Message::LayoutCreate => self.on_layout_create(),

            Message::LayoutDelete(name) => {
                let i = self.active_tab;
                self.push_undo_snapshot(i, "LAYOUT DEL");
                if self.tabs[i].scene.delete_layout(&name) {
                    self.layout_context_menu = None;
                    self.layout_rename_state = None;
                    self.command_line
                        .push_output(&format!("Layout \"{name}\" silindi"));
                    self.tabs[i].dirty = true;
                }
                Task::none()
            }

            Message::LayoutRenameStart(name) => {
                if name != "Model" {
                    self.layout_rename_state = Some((name.clone(), name));
                    self.layout_context_menu = None;
                    // 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()
            }

            Message::LayoutContextMenu(name) => {
                // No menu on the transient BEDIT block tab: Rename/Delete are
                // layout operations and don't apply to it.
                let is_block_tab = self.tabs[self.active_tab]
                    .block_edit
                    .as_ref()
                    .map(|be| be.block_name == name)
                    .unwrap_or(false);
                if name != "Model" && !is_block_tab {
                    self.doc_tab_context_menu = None;
                    self.layout_context_menu = Some(name);
                }
                Task::none()
            }

            Message::LayoutContextMenuClose => {
                self.layout_context_menu = None;
                Task::none()
            }

            // ── Layout Manager Panel ──────────────────────────────────────────
            Message::LayoutManagerOpen => {
                let i = self.active_tab;
                if self.tabs[i].is_start {
                    self.command_line
                        .push_info("Open or create a drawing to manage layouts.");
                    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("Cannot rename the Model layout.");
                } else if new_name.is_empty() {
                    self.command_line.push_error("Layout name cannot be empty.");
                } 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(&format!("Layout renamed: '{old_name}' → '{new_name}'"));
                }
                Task::none()
            }
            Message::LayoutManagerNew => {
                let i = self.active_tab;
                if self.tabs[i].is_start {
                    self.command_line
                        .push_info("Open or create a drawing to add a layout.");
                    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.document.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(&format!("Layout '{name}' created."));
                    }
                    Err(e) => self.command_line.push_error(&format!("LAYOUT: {e}")),
                }
                Task::none()
            }
            Message::LayoutManagerDelete => {
                let i = self.active_tab;
                let name = self.layout_manager_selected.clone();
                if name == "Model" {
                    self.command_line
                        .push_error("Cannot delete the Model layout.");
                } else {
                    self.push_undo_snapshot(i, "LAYOUT DELETE");
                    self.tabs[i].scene.delete_layout(&name);
                    self.tabs[i].dirty = true;
                    // Switch to Model if active layout was deleted.
                    if self.tabs[i].scene.current_layout == name {
                        self.tabs[i].scene.set_current_layout("Model".to_string());
                    }
                    self.layout_manager_selected = "Model".to_string();
                    self.layout_manager_rename_buf = String::new();
                    self.command_line
                        .push_output(&format!("Layout '{name}' deleted."));
                }
                Task::none()
            }
            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 i = self.active_tab;
                let name = self.layout_manager_selected.clone();
                self.tabs[i].scene.set_current_layout(name.clone());
                self.command_line
                    .push_output(&format!("Switched to layout '{name}'."));
                Task::none()
            }

            Message::SetTheme(theme) => {
                self.active_theme = theme;
                Task::none()
            }

            // ── Keyboard Shortcuts Panel ──────────────────────────────────────
            Message::ShortcutsPanelOpen => {
                self.active_modal = Some(super::ModalKind::Shortcuts);
                Task::none()
            }
            Message::ShortcutsPanelClose => {
                self.close_active_modal();
                Task::none()
            }

            // ── 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.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,
                    }
                }
                Task::none()
            }
            Message::AliasEditorAdd => {
                self.alias_editor_rows.push((String::new(), String::new()));
                Task::none()
            }
            Message::AliasEditorRemove(idx) => {
                if idx < self.alias_editor_rows.len() {
                    self.alias_editor_rows.remove(idx);
                }
                Task::none()
            }
            Message::AliasEditorApply => {
                self.apply_alias_editor_rows();
                self.command_line.push_info(&format!(
                    "{} alias(es) applied.",
                    self.command_aliases.len()
                ));
                Task::none()
            }

            // ── About window ──────────────────────────────────────────────
            Message::AboutOpen => {
                self.active_modal = Some(super::ModalKind::About);
                Task::none()
            }

            Message::CloseModal => {
                self.close_active_modal();
                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::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 = self.vp_size.0 + 440.0;
                            let wh = self.vp_size.1;
                            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 = format!(
                    "Open CAD Studio v{}\nOS: {}\nArch: {}",
                    env!("CARGO_PKG_VERSION"),
                    std::env::consts::OS,
                    std::env::consts::ARCH,
                );
                iced::clipboard::write(info)
            }

            // ── Plugin Manager window ─────────────────────────────────────
            Message::PluginManagerOpen => {
                // 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.active_modal = Some(super::ModalKind::PluginManager);
                // Fetch the curated registry and release lists for linked repos.
                #[cfg(not(target_arch = "wasm32"))]
                {
                    let mut tasks = vec![self.fetch_registry_task()];
                    tasks.extend(
                        self.plugin_repos
                            .clone()
                            .into_iter()
                            .map(|r| self.fetch_releases_task(r)),
                    );
                    return Task::batch(tasks);
                }
                #[cfg(target_arch = "wasm32")]
                Task::none()
            }
            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::PluginRepoAdd => {
                let repo = self
                    .plugin_repo_input
                    .trim()
                    .trim_start_matches("https://github.com/")
                    .trim_end_matches('/')
                    .to_string();
                if repo.is_empty() || self.plugin_repos.contains(&repo) {
                    return Task::none();
                }
                self.plugin_repos.push(repo.clone());
                self.plugin_repo_input.clear();
                self.persist_settings_if_changed();
                self.marketplace_status = format!("Fetching releases for {repo}…");
                self.fetch_releases_task(repo)
            }
            Message::PluginRepoRemove(repo) => {
                self.plugin_repos.retain(|r| r != &repo);
                self.repo_release_tags.remove(&repo);
                self.repo_selected_tag.remove(&repo);
                self.persist_settings_if_changed();
                Task::none()
            }
            Message::PluginRegistryFetched(Ok(entries)) => {
                // Fetch releases for every curated repo so the dropdowns fill in.
                #[cfg(not(target_arch = "wasm32"))]
                {
                    let tasks: Vec<_> = entries
                        .iter()
                        .map(|e| self.fetch_releases_task(e.repo.clone()))
                        .collect();
                    self.plugin_registry = entries;
                    return Task::batch(tasks);
                }
                #[cfg(target_arch = "wasm32")]
                {
                    self.plugin_registry = entries;
                    Task::none()
                }
            }
            Message::PluginRegistryFetched(Err(e)) => {
                self.marketplace_status = format!("Registry: {e}");
                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::RecentThumbsLoaded(thumbs) => {
                for (path, handle) in thumbs {
                    self.recent_thumbs.insert(path, handle);
                }
                Task::none()
            }
            Message::PluginReleasesFetched(repo, Ok(tags)) => {
                if let Some(first) = tags.first() {
                    self.repo_selected_tag
                        .entry(repo.clone())
                        .or_insert_with(|| first.clone());
                }
                self.marketplace_status = format!("{repo}: {} installable release(s)", tags.len());
                self.repo_release_tags.insert(repo, tags);
                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::PluginInstall(repo) => {
                let Some(tag) = self.repo_selected_tag.get(&repo).cloned() else {
                    return Task::none();
                };
                self.marketplace_status = format!("Installing {repo} {tag}…");
                self.install_task(repo, tag)
            }
            Message::PluginInstalled(Ok(id)) => {
                self.marketplace_status = format!("Installed '{id}'. Restart to load it.");
                #[cfg(not(target_arch = "wasm32"))]
                {
                    self.external_plugins = crate::plugin::external::discover();
                }
                Task::none()
            }
            Message::PluginInstalled(Err(e)) => {
                self.marketplace_status = format!("Install failed: {e}");
                Task::none()
            }
            Message::PluginUninstall(id) => {
                #[cfg(not(target_arch = "wasm32"))]
                {
                    match crate::plugin::external::uninstall(&id) {
                        Ok(()) => {
                            self.marketplace_status =
                                format!("Uninstalled '{id}'. Restart to unload it.");
                            self.external_plugins = crate::plugin::external::discover();
                        }
                        Err(e) => {
                            self.marketplace_status = format!("Uninstall failed: {e}");
                        }
                    }
                }
                #[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 perf = crate::perf::enabled();
                let total = Instant::now();
                // 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("MSPACE");
                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(),
                    );
                }
                Task::none()
            }

            Message::ExitViewport => {
                let i = self.active_tab;
                // 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("PSPACE");
                Task::none()
            }

            Message::MspaceCommand => {
                let i = self.active_tab;
                if self.tabs[i].scene.current_layout == "Model" {
                    self.command_line
                        .push_error("MS is only available in paper space layouts.");
                    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("No viewport found in this layout.");
                        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(),

            // ── 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(),

            #[cfg(not(target_arch = "wasm32"))]
            Message::SaveFinished(outcome) => self.on_save_finished(outcome),

            #[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.active_modal = Some(super::ModalKind::UpdateNotice);
                Task::none()
            }
            Message::UpdateNoticeClose => {
                self.close_active_modal();
                Task::none()
            }
            Message::UpdateNoticeOpenRelease => {
                crate::sys::open_url(crate::io::update_check::RELEASES_PAGE);
                self.close_active_modal();
                Task::none()
            }
            Message::AssocPromptYes => {
                self.file_assoc_enabled = true;
                self.mark_assoc_prompted();
                self.active_modal = None;
                // 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.active_modal = None;
                Task::none()
            }
            Message::AssocResult(result) => {
                match result {
                    Ok(msg) => self.command_line.push_info(&msg),
                    Err(err) => self
                        .command_line
                        .push_error(&format!("Could not set default app: {err}")),
                }
                Task::none()
            }
            Message::PlotDialogOpen => self.on_plot_dialog_open(),
            Message::PlotDlg(m) => self.on_plot_dlg(m),

            // ── 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());
                Task::perform(
                    crate::io::pdf_export::pick_pdf_path_owned(stem),
                    Message::PlotExportPath,
                )
            }
            // None means the save dialog was cancelled — or never opened at
            // all (a broken XDG portal / missing zenity on Linux resolves to
            // None too, and rfd only reports that through `log`, which is
            // opt-in). Either way say something instead of silently doing
            // nothing. (#369)
            Message::PlotExportPath(None) => {
                self.command_line.push_info(
                    "PDF export canceled — no file chosen. \
                     If no dialog appeared, use EXPORTPDF <path>.",
                );
                Task::none()
            }
            Message::PlotExportPath(Some(path)) => self.on_plot_export_path_some(path),

            Message::PlotFormat(f) => {
                self.plot_format = f;
                Task::none()
            }
            Message::PlotOrientation(o) => {
                self.plot_orientation = o;
                Task::none()
            }
            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());
                Task::perform(
                    crate::io::pdf_export::pick_pdf_path_owned(stem),
                    Message::PlotWindowExportPath,
                )
            }
            // Same silent-None trap as PlotExportPath above. (#369)
            Message::PlotWindowExportPath(None) => {
                self.command_line.push_info(
                    "PDF export canceled — no file chosen. \
                     If no dialog appeared, use EXPORTPDF <path>.",
                );
                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()
            }

            // ── Print to system printer ───────────────────────────────────────
            Message::PrintToPrinter => self.on_print_to_printer(),
            Message::PrintResult(Ok(printer)) => {
                self.command_line
                    .push_info(&format!("Sent to printer: {printer}"));
                Task::none()
            }
            Message::PrintResult(Err(e)) => {
                self.command_line.push_error(&format!("Print failed: {e}"));
                Task::none()
            }

            // ── Plot Style Table ──────────────────────────────────────────────
            Message::PlotStyleLoad => {
                Task::perform(crate::io::pick_plot_style(), Message::PlotStyleLoaded)
            }
            Message::PlotStyleLoaded(Some(table)) => {
                self.command_line.push_output(&format!(
                    "Plot style '{}' loaded ({} color entries).",
                    table.name,
                    table
                        .aci_entries
                        .iter()
                        .filter(|e| e.color.is_some())
                        .count()
                ));
                self.active_plot_style = Some(table);
                Task::none()
            }
            Message::PlotStyleLoaded(None) => Task::none(),
            Message::PlotStyleClear => {
                self.active_plot_style = None;
                self.command_line.push_output("Plot style table cleared.");
                Task::none()
            }

            // ── Plot Style Panel ──────────────────────────────────────────────
            Message::PlotStylePanelOpen => {
                // 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());
                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;
                Task::none()
            }
            Message::PlotStylePanelLwBuf(s) => {
                self.ps_lineweight_buf = s;
                Task::none()
            }
            Message::PlotStylePanelScreenBuf(s) => {
                self.ps_screening_buf = s;
                Task::none()
            }

            Message::PlotStylePanelApply => self.on_plot_style_panel_apply(),

            Message::PlotStylePanelSave => self.on_plot_style_panel_save(),

            Message::PlotStylePanelSavePath(Some(path)) => {
                if let Some(table) = &self.active_plot_style {
                    match table.save(&path) {
                        Ok(()) => self.command_line.push_output(&format!(
                            "Plot style table saved to \"{}\".",
                            path.display()
                        )),
                        Err(e) => self.command_line.push_error(&format!("Save error: {e}")),
                    }
                }
                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::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() {
                    self.tabs[i].scene.document.header.current_text_style_name = name.clone();
                    self.sync_ribbon_styles();
                    self.command_line
                        .push_output(&format!("Current text style: {}", name));
                }
                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) {
                    match field {
                        "backward" => s.flags.backward = !s.flags.backward,
                        "upside_down" => s.flags.upside_down = !s.flags.upside_down,
                        "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;
                self.textstyle_font = font_file.clone();
                let name = self.textstyle_selected.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 acadrust::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) => {
                self.stage_tablestyle_bufs();
                self.tablestyle_selected = name;
                let i = self.active_tab;
                self.load_tablestyle_bufs(i);
                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 => {
                self.stage_tablestyle_bufs();
                self.style_stage_commit();
                Task::none()
            }

            Message::TableStyleSetFlow(value) => {
                use acadrust::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 acadrust::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 acadrust::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 acadrust::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 acadrust::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(&format!("Current table style: {name}"));
                }
                Task::none()
            }

            // ── MLineStyle Dialog ─────────────────────────────────────────────
            Message::MlStyleDialogOpen => self.on_ml_style_dialog_open(),
            Message::MlStyleDialogClose => {
                self.close_active_modal();
                Task::none()
            }
            Message::MlStyleDialogSelect(name) => {
                self.mlstyle_selected = name;
                Task::none()
            }
            Message::MlStyleDialogSetCurrent => {
                use acadrust::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(&format!("Current multiline style: {}", name));
                }
                Task::none()
            }
            // Placeholder so the Multiline manager has the same Set Current +
            // Apply pair as every other style manager. The editor is currently
            // read-only, so there is nothing to apply yet — wire this up when
            // editable MLineStyle properties land.
            Message::MlStyleApply => {
                // Multiline styles have no editable properties yet; Apply still
                // commits any staged structural / current-style changes.
                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()
            }

            // ── 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::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::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(&format!(
                    "Current dim style set to '{}'.",
                    self.dimstyle_selected
                ));
                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) => {
                self.apply_ds_toggle(field);
                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) => {
                self.color_pick_target = Some(target);
                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].layers.color_picker_row = None;
                // Shown as a nested modal over the active dialog (Plan B):
                // `color_pick_target.is_some()` drives the overlay in view_main.
                Task::none()
            }
            Message::CloseColorPicker => {
                self.color_pick_target = None;
                Task::none()
            }
            Message::ColorWindowPick(color) => self.on_color_window_pick(color),
            Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value),
        }
    }

    /// 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 acadrust::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;
        }
    }
}
