use super::document::DocumentTab;
use super::document::{DynComponent, DynFieldEntry};
use super::helpers::grid_plane_from_camera;
use super::history::history_dropdown_labels;
use super::{Message, OpenCADStudio};
use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper};
use crate::scene::view::viewport_pane::ViewportPane;
use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD};
use crate::ui::overlay;
use iced::widget::{
    button, column, container, mouse_area, pick_list, row, shader, stack, text, text_input, Row,
    Space,
};
use iced::window;
use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme};

const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX;

/// `pick_list` requires its items to implement `Display`; acadrust's
/// `ViewportRenderMode` enum carries the raw DXF integers, not a label,
/// so wrap it locally with a friendly name renderer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct RenderModeChoice(pub acadrust::entities::ViewportRenderMode);

impl std::fmt::Display for RenderModeChoice {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        use acadrust::entities::ViewportRenderMode as M;
        f.write_str(match self.0 {
            M::Wireframe2D => "Wireframe 2D",
            M::Wireframe3D => "Wireframe 3D",
            M::HiddenLine => "Hidden Line",
            M::FlatShaded => "Flat Shaded",
            M::GouraudShaded => "Gouraud Shaded",
            M::FlatShadedWithEdges => "Flat Shaded + Edges",
            M::GouraudShadedWithEdges => "Gouraud Shaded + Edges",
        })
    }
}

impl OpenCADStudio {
    pub fn view(&self, window_id: window::Id) -> Element<'_, Message> {
        // ── Floating panel windows ─────────────────────────────────────────
        if Some(window_id) == self.layer_window {
            let tab = &self.tabs[self.active_tab];
            return tab.layers.view_window();
        }
        if Some(window_id) == self.page_setup_window {
            return crate::ui::page_setup::view_window(
                &self.page_setup_w,
                &self.page_setup_h,
                &self.page_setup_plot_area,
                self.page_setup_center,
                &self.page_setup_offset_x,
                &self.page_setup_offset_y,
                &self.page_setup_rotation,
                &self.page_setup_scale,
            );
        }
        if Some(window_id) == self.textstyle_window {
            let tab = &self.tabs[self.active_tab];
            let styles: Vec<String> = tab
                .scene
                .document
                .text_styles
                .iter()
                .map(|s| s.name.clone())
                .collect();
            let (backward, upside_down, annotative) = tab
                .scene
                .document
                .text_styles
                .get(&self.textstyle_selected)
                .map(|s| (s.flags.backward, s.flags.upside_down, s.annotative))
                .unwrap_or((false, false, false));
            return crate::ui::textstyle::view_window(crate::ui::textstyle::TextStyleView {
                styles,
                selected: &self.textstyle_selected,
                current: &tab.scene.document.header.current_text_style_name,
                font_buf: &self.textstyle_font,
                width_buf: &self.textstyle_width,
                oblique_buf: &self.textstyle_oblique,
                height_buf: &self.textstyle_height,
                bigfont_buf: &self.textstyle_bigfont,
                ttf_buf: &self.textstyle_ttf,
                backward,
                upside_down,
                annotative,
                rename_active: self.style_rename.as_deref(),
                rename_buf: &self.style_rename_buf,
            });
        }
        if Some(window_id) == self.tablestyle_window {
            use acadrust::objects::ObjectType;
            let tab = &self.tabs[self.active_tab];
            let styles: Vec<String> = tab
                .scene
                .document
                .objects
                .values()
                .filter_map(|o| {
                    if let ObjectType::TableStyle(s) = o {
                        Some(s.name.clone())
                    } else {
                        None
                    }
                })
                .collect();
            let selected_style = tab.scene.document.objects.values().find_map(|o| {
                if let ObjectType::TableStyle(s) = o {
                    if s.name == self.tablestyle_selected {
                        Some(s)
                    } else {
                        None
                    }
                } else {
                    None
                }
            });
            return crate::ui::tablestyle::view_window(
                styles,
                &self.tablestyle_selected,
                &self.ribbon.active_table_style,
                selected_style,
                &self.ts_hmargin,
                &self.ts_vmargin,
                &self.ts_description,
                &self.ts_cell_textstyle,
                &self.ts_cell_height,
                &self.ts_cell_textcolor,
                &self.ts_cell_fillcolor,
                &self.ts_cell_datatype,
                &self.ts_cell_unittype,
                &self.ts_cell_format,
                &self.ts_border_lw,
                &self.ts_border_color,
                &self.ts_border_spacing,
                self.style_rename.as_deref(),
                &self.style_rename_buf,
                self.ts_color_open,
            );
        }
        if Some(window_id) == self.mlstyle_window {
            use acadrust::objects::ObjectType;
            let tab = &self.tabs[self.active_tab];
            let styles: Vec<String> = tab
                .scene
                .document
                .objects
                .values()
                .filter_map(|o| {
                    if let ObjectType::MLineStyle(s) = o {
                        Some(s.name.clone())
                    } else {
                        None
                    }
                })
                .collect();
            let selected_style = tab.scene.document.objects.values().find_map(|o| {
                if let ObjectType::MLineStyle(s) = o {
                    if s.name == self.mlstyle_selected {
                        Some(s)
                    } else {
                        None
                    }
                } else {
                    None
                }
            });
            return crate::ui::mlstyle::view_window(
                styles,
                &self.mlstyle_selected,
                selected_style,
                tab.scene.document.header.multiline_style.clone(),
                self.style_rename.as_deref(),
                &self.style_rename_buf,
            );
        }
        if Some(window_id) == self.mleaderstyle_window {
            use acadrust::objects::ObjectType;
            let tab = &self.tabs[self.active_tab];
            let styles: Vec<String> = tab
                .scene
                .document
                .objects
                .values()
                .filter_map(|o| {
                    if let ObjectType::MultiLeaderStyle(s) = o {
                        Some(s.name.clone())
                    } else {
                        None
                    }
                })
                .collect();
            let selected_style = tab.scene.document.objects.values().find_map(|o| {
                if let ObjectType::MultiLeaderStyle(s) = o {
                    if s.name == self.mleaderstyle_selected {
                        Some(s)
                    } else {
                        None
                    }
                } else {
                    None
                }
            });
            let doc = &tab.scene.document;
            let mut block_opts: Vec<String> = vec!["None".to_string()];
            block_opts.extend(doc.block_records.iter().map(|b| b.name.clone()));
            let mut lt_opts: Vec<String> = vec!["None".to_string()];
            lt_opts.extend(doc.line_types.iter().map(|lt| lt.name.clone()));
            let mut textstyle_opts: Vec<String> = vec!["None".to_string()];
            textstyle_opts.extend(doc.text_styles.iter().map(|t| t.name.clone()));
            let opt_block = |h: Option<acadrust::types::Handle>| -> String {
                match h {
                    Some(h) => doc
                        .block_records
                        .iter()
                        .find(|b| b.handle == h)
                        .map(|b| b.name.clone())
                        .unwrap_or_else(|| "None".to_string()),
                    None => "None".to_string(),
                }
            };
            let opt_lt = |h: Option<acadrust::types::Handle>| -> String {
                match h {
                    Some(h) => doc
                        .line_types
                        .iter()
                        .find(|lt| lt.handle == h)
                        .map(|lt| lt.name.clone())
                        .unwrap_or_else(|| "None".to_string()),
                    None => "None".to_string(),
                }
            };
            let opt_ts = |h: Option<acadrust::types::Handle>| -> String {
                match h {
                    Some(h) => doc
                        .text_styles
                        .iter()
                        .find(|t| t.handle == h)
                        .map(|t| t.name.clone())
                        .unwrap_or_else(|| "None".to_string()),
                    None => "None".to_string(),
                }
            };
            let (line_type_name, arrowhead_name, text_style_name, block_content_name) =
                match selected_style {
                    Some(s) => (
                        opt_lt(s.line_type_handle),
                        opt_block(s.arrowhead_handle),
                        opt_ts(s.text_style_handle),
                        opt_block(s.block_content_handle),
                    ),
                    None => Default::default(),
                };
            return crate::ui::mleaderstyle::view_window(
                crate::ui::mleaderstyle::MLeaderStyleView {
                    styles,
                    selected: &self.mleaderstyle_selected,
                    style: selected_style,
                    current: tab.active_mleader_style.clone(),
                    landing_distance: &self.mls_landing_distance,
                    landing_gap: &self.mls_landing_gap,
                    arrowhead_size: &self.mls_arrowhead_size,
                    text_height: &self.mls_text_height,
                    scale_factor: &self.mls_scale_factor,
                    break_gap: &self.mls_break_gap,
                    first_seg_angle: &self.mls_first_seg_angle,
                    second_seg_angle: &self.mls_second_seg_angle,
                    max_points: &self.mls_max_points,
                    default_text: &self.mls_default_text,
                    line_color: &self.mls_line_color,
                    text_color: &self.mls_text_color,
                    description: &self.mls_description,
                    line_weight: &self.mls_line_weight,
                    align_space: &self.mls_align_space,
                    block_color: &self.mls_block_color,
                    block_rotation: &self.mls_block_rotation,
                    block_scale_x: &self.mls_block_scale_x,
                    block_scale_y: &self.mls_block_scale_y,
                    block_scale_z: &self.mls_block_scale_z,
                    block_opts,
                    lt_opts,
                    textstyle_opts,
                    line_type_name,
                    arrowhead_name,
                    text_style_name,
                    block_content_name,
                    rename_active: self.style_rename.as_deref(),
                    rename_buf: &self.style_rename_buf,
                    color_open: self.mls_color_open,
                },
            );
        }
        if Some(window_id) == self.layout_manager_window {
            let i = self.active_tab;
            let layouts = self.tabs[i].scene.layout_names();
            let current = self.tabs[i].scene.current_layout.clone();
            return crate::ui::layout_manager::view_window(
                layouts,
                &self.layout_manager_selected,
                &self.layout_manager_rename_buf,
                current,
            );
        }
        if Some(window_id) == self.plotstyle_window {
            return crate::ui::plotstyle::view_window(
                self.active_plot_style.as_ref(),
                self.plotstyle_panel_aci,
                &self.ps_color_buf,
                &self.ps_lineweight_buf,
                &self.ps_screening_buf,
            );
        }
        if Some(window_id) == self.dimstyle_window {
            let tab = &self.tabs[self.active_tab];
            let styles: Vec<String> = tab
                .scene
                .document
                .dim_styles
                .iter()
                .map(|s| s.name.clone())
                .collect();
            let doc = &tab.scene.document;
            // Dropdown options (names must match the records exactly so the
            // selection can be resolved back to a handle on the update side).
            let mut block_opts: Vec<String> = vec!["Default".to_string()];
            block_opts.extend(doc.block_records.iter().map(|b| b.name.clone()));
            let mut lt_opts: Vec<String> = vec!["ByBlock".to_string()];
            lt_opts.extend(doc.line_types.iter().map(|lt| lt.name.clone()));
            let blk_name = |h: acadrust::types::Handle| -> String {
                if h.is_null() {
                    "Default".to_string()
                } else {
                    doc.block_records
                        .iter()
                        .find(|b| b.handle == h)
                        .map(|b| b.name.clone())
                        .unwrap_or_else(|| "Default".to_string())
                }
            };
            let lt_name = |h: acadrust::types::Handle| -> String {
                if h.is_null() {
                    "ByBlock".to_string()
                } else {
                    doc.line_types
                        .iter()
                        .find(|lt| lt.handle == h)
                        .map(|lt| lt.name.clone())
                        .unwrap_or_else(|| "ByBlock".to_string())
                }
            };
            let ds_sel = doc.dim_styles.get(&self.dimstyle_selected);
            let (
                dimblk_name,
                dimblk1_name,
                dimblk2_name,
                dimldrblk_name,
                dimltex_name,
                dimltex1_name,
                dimltex2_name,
            ) = match ds_sel {
                Some(d) => (
                    blk_name(d.dimblk),
                    blk_name(d.dimblk1),
                    blk_name(d.dimblk2),
                    blk_name(d.dimldrblk),
                    lt_name(d.dimltex_handle),
                    lt_name(d.dimltex1_handle),
                    lt_name(d.dimltex2_handle),
                ),
                None => Default::default(),
            };
            return crate::ui::dimstyle::view_window(
                styles,
                &self.dimstyle_selected,
                &self.tabs[self.active_tab]
                    .scene
                    .document
                    .header
                    .current_dimstyle_name,
                self.dimstyle_tab,
                crate::ui::dimstyle::DimStyleValues {
                    dimdle: &self.ds_dimdle,
                    dimdli: &self.ds_dimdli,
                    dimgap: &self.ds_dimgap,
                    dimexe: &self.ds_dimexe,
                    dimexo: &self.ds_dimexo,
                    dimsd1: self.ds_dimsd1,
                    dimsd2: self.ds_dimsd2,
                    dimse1: self.ds_dimse1,
                    dimse2: self.ds_dimse2,
                    dimasz: &self.ds_dimasz,
                    dimcen: &self.ds_dimcen,
                    dimtsz: &self.ds_dimtsz,
                    dimtxt: &self.ds_dimtxt,
                    dimtxsty: &self.ds_dimtxsty,
                    dimtad: &self.ds_dimtad,
                    dimtih: self.ds_dimtih,
                    dimtoh: self.ds_dimtoh,
                    dimscale: &self.ds_dimscale,
                    dimlfac: &self.ds_dimlfac,
                    dimlunit: &self.ds_dimlunit,
                    dimdec: &self.ds_dimdec,
                    dimpost: &self.ds_dimpost,
                    dimtol: self.ds_dimtol,
                    dimlim: self.ds_dimlim,
                    dimtp: &self.ds_dimtp,
                    dimtm: &self.ds_dimtm,
                    dimtdec: &self.ds_dimtdec,
                    dimtfac: &self.ds_dimtfac,
                    annotative: self.ds_annotative,
                    dimclrd: &self.ds_dimclrd,
                    dimlwd: &self.ds_dimlwd,
                    dimclre: &self.ds_dimclre,
                    dimlwe: &self.ds_dimlwe,
                    dimfxl: &self.ds_dimfxl,
                    dimfxlon: self.ds_dimfxlon,
                    dimsah: self.ds_dimsah,
                    dimarcsym: &self.ds_dimarcsym,
                    dimjogang: &self.ds_dimjogang,
                    dimclrt: &self.ds_dimclrt,
                    dimjust: &self.ds_dimjust,
                    dimtvp: &self.ds_dimtvp,
                    dimtfill: &self.ds_dimtfill,
                    dimtfillclr: &self.ds_dimtfillclr,
                    dimtxtdirection: self.ds_dimtxtdirection,
                    dimatfit: &self.ds_dimatfit,
                    dimtix: self.ds_dimtix,
                    dimsoxd: self.ds_dimsoxd,
                    dimtmove: &self.ds_dimtmove,
                    dimupt: self.ds_dimupt,
                    dimtofl: self.ds_dimtofl,
                    dimfit: &self.ds_dimfit,
                    dimdsep: &self.ds_dimdsep,
                    dimrnd: &self.ds_dimrnd,
                    dimzin: &self.ds_dimzin,
                    dimfrac: &self.ds_dimfrac,
                    dimaunit: &self.ds_dimaunit,
                    dimadec: &self.ds_dimadec,
                    dimunit: &self.ds_dimunit,
                    dimazin: &self.ds_dimazin,
                    dimalt: self.ds_dimalt,
                    dimaltf: &self.ds_dimaltf,
                    dimaltd: &self.ds_dimaltd,
                    dimaltu: &self.ds_dimaltu,
                    dimalttd: &self.ds_dimalttd,
                    dimaltrnd: &self.ds_dimaltrnd,
                    dimapost: &self.ds_dimapost,
                    dimaltz: &self.ds_dimaltz,
                    dimalttz: &self.ds_dimalttz,
                    dimtolj: &self.ds_dimtolj,
                    dimtzin: &self.ds_dimtzin,
                    dimblk_name,
                    dimblk1_name,
                    dimblk2_name,
                    dimldrblk_name,
                    dimltex_name,
                    dimltex1_name,
                    dimltex2_name,
                    block_opts,
                    lt_opts,
                    color_open: self.ds_color_open.clone(),
                },
                self.style_rename.as_deref(),
                &self.style_rename_buf,
            );
        }
        if Some(window_id) == self.color_pick_window {
            return crate::ui::color_select::color_grid_window(Message::ColorWindowPick);
        }
        if Some(window_id) == self.shortcuts_window {
            return crate::ui::shortcuts::view_window(&self.shortcut_overrides);
        }
        if Some(window_id) == self.about_window {
            return crate::ui::about::view_window();
        }
        if Some(window_id) == self.plugin_manager_window {
            return crate::ui::plugin_manager::view_window(&crate::plugin::installed_manifests());
        }
        if Some(window_id) == self.update_notice_window {
            let latest = self.update_notice_version.as_deref().unwrap_or("?");
            let body = self.update_notice_body.as_deref().unwrap_or("");
            return crate::ui::update_notice::view_window(latest, body);
        }
        if Some(window_id) == self.assoc_prompt_window {
            return default_assoc_dialog_window();
        }
        if Some(window_id) == self.unsaved_dialog_window {
            let tab_name = match &self.pending_close {
                Some(super::PendingClose::Tab(idx)) => self
                    .tabs
                    .get(*idx)
                    .map(|t| t.tab_display_name())
                    .unwrap_or_default(),
                Some(super::PendingClose::Quit) => self
                    .tabs
                    .iter()
                    .find(|t| t.dirty)
                    .map(|t| t.tab_display_name())
                    .unwrap_or_default(),
                None => String::new(),
            };
            return unsaved_changes_dialog_window(&tab_name);
        }
        if Some(window_id) == self.save_dialog_window {
            return save_as_dialog_window(
                &self.save_dialog_filename,
                &self.save_dialog_folder,
                &self.save_dialog_entries,
                &self.save_dialog_format,
            );
        }

        self.view_main()
    }

    /// The primary window: viewport, ribbon, tab bar, status bar. Split out of
    /// `view` so the single-window web build can render it directly, bypassing
    /// the multi-window id dispatch above (the web build has no extra windows).
    pub fn view_main(&self) -> Element<'_, Message> {
        let i = self.active_tab;
        let tab = &self.tabs[i];
        let is_paper = tab.scene.current_layout != "Model";
        // Start tab: render welcome page in place of the viewport.
        // Surrounding chrome (tab bar, status bar) stays; the welcome widget
        // returned here also flags the rest of `view` to skip drawing-only
        // overlays via `tab.is_start`.
        // Unified GPU widget for both layouts. A paper layout renders through
        // the same shader as model space: a full-canvas top-locked "sheet"
        // viewport draws the layout's own geometry (white sheet + entities +
        // borders) and the floating content viewports blit on top.
        let viewport_3d: Element<'_, Message> = if tab.is_start {
            start_page_view()
        } else {
            shader(ViewportPane::model(
                &tab.scene,
                self.show_viewcube,
                tab.render_mode,
            ))
            .width(Fill)
            .height(Fill)
            .into()
        };

        let selection_overlay = {
            let sel = tab.scene.selection.borrow().clone();
            let snap_info = tab.snap_result.map(|s| (s.screen, s.snap_type));

            let grips: Vec<overlay::GripMarker> =
                if tab.active_cmd.is_none() && !tab.selected_grips.is_empty() {
                    let (vw, vh) = tab.scene.selection.borrow().vp_size;
                    // Overlays project through the active tile's camera, so
                    // they must use the active tile's screen rectangle (with
                    // its canvas offset) — not the whole canvas — or they
                    // land in the wrong place in a tiled layout.
                    let bounds = tab.scene.active_model_tile_bounds(vw, vh);
                    let sel_h = tab.selected_handle;
                    let screen_grips = if is_paper {
                        let cam = tab.scene.camera.borrow();
                        let aspect = if vh > 0.0 { vw / vh } else { 1.0 };
                        let half_h = cam.ortho_size();
                        let half_w = half_h * aspect;
                        let tx = cam.target.x;
                        let ty = cam.target.y;
                        drop(cam);
                        grips_to_screen_paper(&tab.selected_grips, tx, ty, half_w, half_h, bounds)
                    } else {
                        let vp_mat = tab.scene.camera.borrow().view_proj(bounds);
                        grips_to_screen(&tab.selected_grips, vp_mat, bounds)
                    };
                    screen_grips
                        .into_iter()
                        .filter(|(_, screen, _, _, _)| {
                            screen.x.is_finite()
                                && screen.y.is_finite()
                                && screen.x >= -bounds.width
                                && screen.x <= bounds.width * 2.0
                                && screen.y >= -bounds.height
                                && screen.y <= bounds.height * 2.0
                        })
                        .map(|(grip_id, screen, _is_midpoint, shape, dir)| {
                            let is_hot = tab
                                .active_grip
                                .as_ref()
                                .map_or(false, |g| Some(g.handle) == sel_h && g.grip_id == grip_id);
                            overlay::GripMarker {
                                pos: screen,
                                shape,
                                is_hot,
                                dir,
                            }
                        })
                        .collect()
                } else {
                    vec![]
                };

            let (vw, vh) = tab.scene.selection.borrow().vp_size;
            // Active tile rectangle (canvas-offset included) so grid / UCS
            // icon / crosshair project through the active pane's camera at
            // the correct place and scale.
            let vp_bounds = tab.scene.active_model_tile_bounds(vw, vh);

            let grid = if self.show_grid {
                let cam = tab.scene.camera.borrow();
                let plane = grid_plane_from_camera(cam.pitch, cam.yaw);
                Some(overlay::GridParams {
                    view_proj: cam.view_proj(vp_bounds),
                    bounds: vp_bounds,
                    plane,
                })
            } else {
                None
            };

            let ucs_icon = if self.show_ucs_icon && !is_paper {
                let cam = tab.scene.camera.borrow();
                Some(overlay::UcsIconParams {
                    view_proj: cam.view_proj(vp_bounds),
                    bounds: vp_bounds,
                })
            } else {
                None
            };

            // OST tracking points → screen positions.
            let ost_points: Vec<overlay::OstTrackPoint> = if self.snapper.otrack_enabled {
                let vp_mat = tab.scene.camera.borrow().view_proj(vp_bounds);
                self.snapper
                    .tracking_points
                    .iter()
                    .map(|&wp| {
                        let ndc = vp_mat.project_point3(wp);
                        overlay::OstTrackPoint {
                            screen: iced::Point::new(
                                (ndc.x + 1.0) * 0.5 * vp_bounds.width,
                                (1.0 - ndc.y) * 0.5 * vp_bounds.height,
                            ),
                        }
                    })
                    .collect()
            } else {
                vec![]
            };

            // Model-space tile dividers (none in paper / single-tile layouts).
            let tile_edges = if !is_paper {
                tab.scene.model_tile_edges()
            } else {
                vec![]
            };

            overlay::selection_overlay(
                sel,
                snap_info,
                grips,
                grid,
                ucs_icon,
                ost_points,
                tab.last_cursor_screen,
                !is_paper && self.show_viewcube,
                tile_edges,
            )
        };

        // Render-mode picker, top-left of the viewport. Drives whether
        // 3D face / mesh fills and edges are rendered for the active
        // tab. Hatch fills are deliberately *not* gated by this — the
        // document's FILLMODE still owns 2D fill state.
        let info = render_mode_picker(tab.render_mode);

        let viewport_mouse = mouse_area(container(
            iced::widget::Space::new().width(Fill).height(Fill),
        ))
        .on_move(Message::ViewportMove)
        .on_press(Message::ViewportLeftPress)
        .on_release(Message::ViewportLeftRelease)
        .on_right_press(Message::ViewportRightPress)
        .on_right_release(Message::ViewportRightRelease)
        .on_middle_press(Message::ViewportMiddlePress)
        .on_middle_release(Message::ViewportMiddleRelease)
        .on_scroll(Message::ViewportScroll)
        .on_exit(Message::ViewportExit);

        let bg_color = if is_paper {
            // Desk color — matches the DESK constant in paper_canvas.rs.
            Color {
                r: 0.22,
                g: 0.24,
                b: 0.28,
                a: 1.0,
            }
        } else {
            tab.bg_color
                .map(|[r, g, b, a]| Color { r, g, b, a })
                .unwrap_or(Color {
                    r: 0.11,
                    g: 0.11,
                    b: 0.11,
                    a: 1.0,
                })
        };

        // Dynamic input overlay — editable boxes near the cursor, one per
        // quantity the active command is asking for (X/Y, or polar
        // distance+angle, or a single distance/angle). TAB moves focus
        // between boxes; typing locks a box to a fixed value while the
        // rest keep tracking the cursor. The field set is maintained in
        // `tab.dyn_fields` by `sync_dyn_fields`.
        // A pick step (object selection) has no input box, but still shows
        // its prompt ("Select first object …") near the cursor as a hint.
        let dyn_picks_object = tab
            .active_cmd
            .as_ref()
            .map(|c| c.needs_entity_pick() || c.needs_structure_point_pick())
            .unwrap_or(false);
        let dyn_input_overlay: Option<Element<'_, Message>> =
            if self.dyn_input
                && tab.active_cmd.is_some()
                && (!tab.dyn_fields.is_empty() || dyn_picks_object)
            {
                let w = tab.last_cursor_world;
                let base = self.last_point;
                // A command may drive a typed scalar by mouse (e.g. a
                // perpendicular distance to a picked object); show that live
                // value in the box until the user types over it.
                let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w));
                let boxes: Vec<overlay::DynBox> = tab
                    .dyn_fields
                    .iter()
                    .enumerate()
                    .map(|(idx, f)| {
                        let value = match (&f.buffer, live) {
                            (Some(b), _) => b.clone(),
                            // An angle step with a command-supplied live value
                            // (ARC span / direction) shows it in degrees.
                            (None, Some(lv)) if f.component == DynComponent::Angle => {
                                format!("{lv:.1}")
                            }
                            (None, Some(lv))
                                if matches!(
                                    f.component,
                                    DynComponent::Scalar | DynComponent::Distance
                                ) =>
                            {
                                format!("{lv:.4}")
                            }
                            _ => dyn_component_value(f, w, base),
                        };
                        overlay::DynBox {
                            label: f.role.label().to_string(),
                            value,
                            active: idx == tab.dyn_active,
                            locked: f.locked(),
                            role: f.role,
                        }
                    })
                    .collect();
                let prompt = tab
                    .active_cmd
                    .as_ref()
                    .map(|c| c.prompt())
                    .unwrap_or_default();
                Some(overlay::dynamic_input_overlay(
                    tab.last_cursor_screen,
                    tab.last_point_screen,
                    tab.dyn_ref_screen,
                    tab.dyn_guide,
                    boxes,
                    prompt,
                ))
            } else {
                None
            };

        let mut viewport_stack = if tab.is_start {
            // Start tab: only the welcome widget over a flat background.
            // Skip every drawing-only overlay (selection markers, snap info,
            // mouse-area capturing draw clicks, viewcube, nav toolbar, …).
            stack![container(viewport_3d)
                .style(move |_: &Theme| container::Style {
                    background: Some(Background::Color(bg_color)),
                    ..Default::default()
                })
                .width(Fill)
                .height(Fill)]
            .width(Fill)
            .height(Fill)
        } else if is_paper {
            // Paper layout: the GPU shader renders everything — the desk is the
            // container background, the white sheet + paper entities + borders
            // come from the full-canvas top-locked "sheet" viewport, and the
            // floating content viewports overlay it (same path as model space).
            const DESK: Color = Color {
                r: 0.22,
                g: 0.24,
                b: 0.28,
                a: 1.0,
            };
            stack![
                container(viewport_3d)
                    .style(move |_: &Theme| container::Style {
                        background: Some(Background::Color(DESK)),
                        ..Default::default()
                    })
                    .width(Fill)
                    .height(Fill),
                selection_overlay,
                viewport_mouse,
            ]
            .width(Fill)
            .height(Fill)
        } else {
            stack![
                container(viewport_3d)
                    .style(move |_: &Theme| container::Style {
                        background: Some(Background::Color(bg_color)),
                        ..Default::default()
                    })
                    .width(Fill)
                    .height(Fill),
                selection_overlay,
                viewport_mouse,
            ]
            .width(Fill)
            .height(Fill)
        };

        // Model-space render-mode picker, top-left. Sits ABOVE the
        // viewport mouse_area so clicks inside its bounds reach it
        // instead of the shader behind it; `opaque` stops them bubbling
        // further. Outside the chip the Fill container is transparent so
        // viewport drawing / selection is unaffected. In a paper layout
        // the active viewport gets its own picker (below) instead.
        if !is_paper && !tab.is_start {
            // Render-mode picker + viewport-split buttons (horizontal /
            // vertical divider of the active Model tile).
            let split_btn = |glyph: &'static str, horizontal: bool| {
                button(text(glyph).size(13).color(Color {
                    r: 0.85,
                    g: 0.85,
                    b: 0.85,
                    a: 1.0,
                }))
                .on_press(Message::SplitModelViewport(horizontal))
                .padding([4, 8])
                .style(|_: &Theme, status| iced::widget::button::Style {
                    background: Some(Background::Color(match status {
                        iced::widget::button::Status::Hovered => Color {
                            r: 0.20,
                            g: 0.20,
                            b: 0.20,
                            a: 0.85,
                        },
                        _ => Color {
                            r: 0.10,
                            g: 0.10,
                            b: 0.10,
                            a: 0.75,
                        },
                    })),
                    border: Border {
                        color: Color {
                            r: 0.35,
                            g: 0.35,
                            b: 0.35,
                            a: 1.0,
                        },
                        width: 1.0,
                        radius: 4.0.into(),
                    },
                    text_color: Color {
                        r: 0.85,
                        g: 0.85,
                        b: 0.85,
                        a: 1.0,
                    },
                    ..Default::default()
                })
            };
            let bar = row![info, split_btn("▤", true), split_btn("▥", false)].spacing(4);
            // Position the bar at the active model tile's top-left corner so
            // it follows the active panel in a tiled layout (full canvas when
            // a single tile fills the window). Leading Spaces offset it.
            let (vw, vh) = tab.scene.selection.borrow().vp_size;
            let rect = tab.scene.active_model_tile_bounds(vw, vh);
            let bar_layer = column![
                Space::new().height(iced::Length::Fixed(rect.y.max(0.0))),
                row![
                    Space::new().width(iced::Length::Fixed(rect.x.max(0.0))),
                    iced::widget::opaque(bar),
                ],
            ]
            .width(Fill)
            .height(Fill);
            viewport_stack = viewport_stack.push(bar_layer);
        }

        // Active paper-space viewport overlays: a render-mode picker in
        // its top-left corner and a ViewCube hit area in its top-right,
        // both layered ABOVE the viewport mouse_area so they receive
        // clicks (the shader viewport sits below it). Positioned with
        // leading Spaces sized to the viewport's screen rectangle.
        let active_vp_rect: Option<iced::Rectangle> = if is_paper && !tab.is_start {
            tab.scene.active_viewport.and_then(|h| {
                let (cw, ch) = tab.scene.selection.borrow().vp_size;
                tab.scene.viewport_screen_rect(h, (cw, ch))
            })
        } else {
            None
        };
        if let Some(rect) = active_vp_rect {
            let x = rect.x.max(0.0);
            let y = rect.y.max(0.0);
            // Highlight the active viewport with a 2-px border so its
            // boundary is always visible over the GPU shader.
            const VP_BORDER: Color = Color {
                r: 0.18,
                g: 0.52,
                b: 0.95,
                a: 1.0,
            };
            let border_frame = container(
                Space::new()
                    .width(iced::Length::Fixed(rect.width.max(1.0)))
                    .height(iced::Length::Fixed(rect.height.max(1.0))),
            )
            .style(move |_: &Theme| container::Style {
                border: iced::Border {
                    color: VP_BORDER,
                    width: 2.0,
                    radius: 0.0.into(),
                },
                ..Default::default()
            });
            let border_layer = column![
                Space::new().height(iced::Length::Fixed(y)),
                row![Space::new().width(iced::Length::Fixed(x)), border_frame,],
            ]
            .width(Fill)
            .height(Fill);
            viewport_stack = viewport_stack.push(border_layer);

            let vp_mode = tab
                .scene
                .active_viewport_render_mode()
                .unwrap_or(acadrust::entities::ViewportRenderMode::Wireframe2D);
            let picker_layer = column![
                Space::new().height(iced::Length::Fixed(y + 4.0)),
                row![
                    Space::new().width(iced::Length::Fixed(x + 4.0)),
                    iced::widget::opaque(render_mode_picker(vp_mode)),
                ],
            ]
            .width(Fill)
            .height(Fill);
            viewport_stack = viewport_stack.push(picker_layer);

            if self.show_viewcube {
                let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0);
                let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0);
                let cube_click = column![
                    Space::new().height(iced::Length::Fixed(cube_y)),
                    row![
                        Space::new().width(iced::Length::Fixed(cube_x)),
                        mouse_area(
                            iced::widget::Space::new()
                                .width(iced::Length::Fixed(VIEWCUBE_HIT_SIZE))
                                .height(iced::Length::Fixed(VIEWCUBE_HIT_SIZE)),
                        )
                        .on_move(Message::CursorMoved)
                        .on_press(Message::ViewportClick),
                    ],
                ]
                .width(Fill)
                .height(Fill);
                viewport_stack = viewport_stack.push(cube_click);
            }
        }

        if self.show_viewcube && !is_paper && !tab.is_start {
            // Place the ViewCube hit area in the active model tile's top-right
            // corner so it tracks the active panel in a tiled layout. The hit
            // test in update.rs already maps clicks through the active tile.
            let (vw, vh) = tab.scene.selection.borrow().vp_size;
            let rect = tab.scene.active_model_tile_bounds(vw, vh);
            let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0);
            let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0);
            let cube_click = column![
                Space::new().height(iced::Length::Fixed(cube_y)),
                row![
                    Space::new().width(iced::Length::Fixed(cube_x)),
                    mouse_area(
                        iced::widget::Space::new()
                            .width(iced::Length::Fixed(VIEWCUBE_HIT_SIZE))
                            .height(iced::Length::Fixed(VIEWCUBE_HIT_SIZE)),
                    )
                    .on_move(Message::CursorMoved)
                    .on_press(Message::ViewportClick),
                ],
            ]
            .width(Fill)
            .height(Fill);
            viewport_stack = viewport_stack.push(cube_click);
        }

        if let Some(dyn_ol) = dyn_input_overlay {
            if !tab.is_start {
                viewport_stack = viewport_stack.push(dyn_ol);
            }
        }

        // Multi-functional grip popup (Phase 2). One bordered container
        // wraps a column of borderless item buttons so the popup reads
        // as a single widget instead of stacked tiles.
        if let Some(popup) = self.grip_popup.as_ref() {
            if !tab.is_start {
                // Size the row to the widest label so the selection
                // highlight fills the whole row instead of just the
                // text glyphs. ~7 px per character at size 12 + the
                // horizontal padding (10 + 10).
                let max_len = popup
                    .items
                    .iter()
                    .map(|i| i.label.chars().count())
                    .max()
                    .unwrap_or(8) as f32;
                let row_w = max_len * 7.0 + 24.0;
                let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w));
                for (idx, item) in popup.items.iter().enumerate() {
                    let is_sel = idx == popup.selected;
                    let label = item.label;
                    let btn = button(text(label).size(12).color(Color::WHITE))
                        .on_press(Message::GripMenuPick(idx))
                        .padding([3, 10])
                        .width(Fill)
                        .style(move |_: &Theme, status| iced::widget::button::Style {
                            background: Some(Background::Color(match (is_sel, status) {
                                (true, _) => Color {
                                    r: 0.20,
                                    g: 0.45,
                                    b: 0.95,
                                    a: 1.0,
                                },
                                (_, iced::widget::button::Status::Hovered) => Color {
                                    r: 0.22,
                                    g: 0.22,
                                    b: 0.22,
                                    a: 1.0,
                                },
                                _ => Color::TRANSPARENT,
                            })),
                            border: Border {
                                color: Color::TRANSPARENT,
                                width: 0.0,
                                radius: 0.0.into(),
                            },
                            text_color: Color::WHITE,
                            ..Default::default()
                        });
                    col = col.push(btn);
                }
                let menu_panel = container(col)
                    .padding(2)
                    .style(|_: &Theme| container::Style {
                        background: Some(Background::Color(Color {
                            r: 0.10,
                            g: 0.10,
                            b: 0.10,
                            a: 0.95,
                        })),
                        border: Border {
                            color: Color {
                                r: 0.40,
                                g: 0.40,
                                b: 0.40,
                                a: 1.0,
                            },
                            width: 1.0,
                            radius: 3.0.into(),
                        },
                        ..Default::default()
                    });
                // Offset the menu by 12 px so the cursor doesn't land on
                // the first item immediately, matching the right-click
                // context menu's "panel below the click point" feel.
                let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0);
                viewport_stack =
                    viewport_stack.push(position_canvas_overlay(anchor, menu_panel.into()));
            }
        }

        // Dynamic-block visibility-state dropdown.
        if let Some(popup) = self.visibility_popup.as_ref() {
            if !tab.is_start {
                let max_len = popup
                    .items
                    .iter()
                    .map(|s| s.chars().count())
                    .max()
                    .unwrap_or(4) as f32;
                // +2 chars for the leading "✓ " / "  " marker column.
                let row_w = (max_len + 2.0) * 7.0 + 24.0;
                let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w));
                for (idx, name) in popup.items.iter().enumerate() {
                    let is_cur = popup.current == Some(idx);
                    let label = format!("{} {}", if is_cur { "✓" } else { "  " }, name);
                    let btn = button(text(label).size(12).color(Color::WHITE))
                        .on_press(Message::VisibilityPick(idx))
                        .padding([3, 10])
                        .width(Fill)
                        .style(move |_: &Theme, status| iced::widget::button::Style {
                            background: Some(Background::Color(match status {
                                iced::widget::button::Status::Hovered => Color {
                                    r: 0.20,
                                    g: 0.45,
                                    b: 0.95,
                                    a: 1.0,
                                },
                                _ => Color::TRANSPARENT,
                            })),
                            border: Border {
                                color: Color::TRANSPARENT,
                                width: 0.0,
                                radius: 0.0.into(),
                            },
                            text_color: Color::WHITE,
                            ..Default::default()
                        });
                    col = col.push(btn);
                }
                let panel = container(iced::widget::scrollable(col).height(iced::Length::Shrink))
                    .max_height(360.0)
                    .padding(2)
                    .style(|_: &Theme| container::Style {
                        background: Some(Background::Color(Color {
                            r: 0.10,
                            g: 0.10,
                            b: 0.10,
                            a: 0.95,
                        })),
                        border: Border {
                            color: Color {
                                r: 0.40,
                                g: 0.40,
                                b: 0.40,
                                a: 1.0,
                            },
                            width: 1.0,
                            radius: 3.0.into(),
                        },
                        ..Default::default()
                    });
                let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0);
                viewport_stack =
                    viewport_stack.push(position_canvas_overlay(anchor, panel.into()));
            }
        }

        // Paper-space context actions: a right-edge vertical toolbar
        // (viewport / page setup / plot) instead of a contextual ribbon tab.
        if is_paper && !tab.is_start {
            if let Some(tb) = crate::ui::side_toolbar::view(
                &crate::modules::layout::paper_space_tools(),
            ) {
                viewport_stack = viewport_stack.push(tb);
            }
        }

        // Quick Properties: compact floating property panel on selection,
        // anchored at the canvas top-left so it doesn't track the cursor.
        if self.quick_properties && !tab.is_start {
            if let Some(panel) = tab.properties.quick_view() {
                viewport_stack = viewport_stack
                    .push(position_canvas_overlay(iced::Point::new(12.0, 12.0), panel));
            }
        }

        // Frame-budget HUD (Phase 5.3): toggle with the PERF command. Shows
        // the cost of the most recent wire re-tessellation — the work avoided
        // by a warm wire cache — so render-path changes can be compared
        // PR-to-PR. Reads ~0 ms while panning/zooming on a hit cache.
        if self.perf_hud && !tab.is_start {
            let s = &tab.scene;
            let label = format!(
                "tess {:.1} ms · {} wires · epoch {}",
                s.last_tess_ms.get(),
                s.last_tess_wires.get(),
                s.geometry_epoch,
            );
            let panel = container(text(label).size(12).color(Color {
                r: 0.6,
                g: 1.0,
                b: 0.6,
                a: 1.0,
            }))
            .padding(6)
            .style(|_: &Theme| container::Style {
                background: Some(Background::Color(Color {
                    r: 0.08,
                    g: 0.08,
                    b: 0.08,
                    a: 0.85,
                })),
                border: Border {
                    color: Color {
                        r: 0.35,
                        g: 0.35,
                        b: 0.35,
                        a: 1.0,
                    },
                    width: 1.0,
                    radius: 3.0.into(),
                },
                ..Default::default()
            });
            viewport_stack = viewport_stack.push(position_canvas_overlay(
                iced::Point::new(12.0, 40.0),
                panel.into(),
            ));
        }

        // Selection-cycling list box: pick among overlapping objects.
        if let Some((pt, cands)) = &self.cycle_candidates {
            if !tab.is_start {
                let items: Vec<(acadrust::Handle, String)> = cands
                    .iter()
                    .filter_map(|&h| {
                        tab.scene
                            .document
                            .get_entity(h)
                            .map(|e| (h, crate::entities::traits::entity_type_name(e).to_string()))
                    })
                    .collect();
                if !items.is_empty() {
                    viewport_stack = viewport_stack
                        .push(crate::ui::cycle_popup::cycle_popup_overlay(*pt, items));
                }
            }
        }

        // Right-click context menu. Lives inside the viewport stack so
        // the cursor position (canvas-relative) anchors the menu under
        // the cursor instead of drifting into window-relative space.
        if !tab.is_start {
            let (ctx_pos, draworder_open) = {
                let sel = tab.scene.selection.borrow();
                (sel.context_menu, sel.draworder_submenu)
            };
            if let Some(p) = ctx_pos {
                let has_cmd = tab.active_cmd.is_some();
                let has_selection = !tab.scene.selected.is_empty();
                let isolation_active = tab.scene.is_isolation_active();
                let last_cmds: Vec<String> = self
                    .command_line
                    .cmd_recall
                    .iter()
                    .rev()
                    .take(3)
                    .cloned()
                    .collect();
                viewport_stack = viewport_stack.push(viewport_context_menu_overlay(
                    p,
                    has_cmd,
                    has_selection,
                    isolation_active,
                    last_cmds,
                    draworder_open,
                ));
            }
        }

        // In-place MText editor (toolbar + text area), anchored at the
        // insertion-point click.
        if !tab.is_start {
            let canvas = tab.scene.selection.borrow().vp_size;
            if let Some(ed) = &self.mtext_editor {
                let styles: Vec<String> = tab
                    .scene
                    .document
                    .text_styles
                    .iter()
                    .map(|s| s.name.clone())
                    .collect();
                viewport_stack = viewport_stack.push(mtext_editor_overlay(ed, styles, canvas));
            }
            if let Some(ed) = &self.text_inline {
                viewport_stack = viewport_stack.push(text_inline_overlay(ed, canvas));
            }
        }

        // Properties / layers panels carry no useful state on the Start tab.
        // Replace the properties panel with a Recent Documents list there.
        let properties_el: Element<'_, Message> = if tab.is_start {
            recent_files_panel(&self.app_menu.recent)
        } else if self.show_properties && !self.clean_screen {
            tab.properties.view()
        } else {
            Space::new().into()
        };

        // Command-line sits as a bottom-centre overlay on top of the
        // viewport stack rather than as a separate row in the main
        // column — frees up vertical space when no command is active
        // and keeps the input close to where the cursor is drawing.
        // Autocomplete shows only when no command is collecting its
        // own input (otherwise typed prefixes are coordinates / values).
        let allow_autocomplete = tab.active_cmd.is_none();
        // Dynamic input captures keystrokes when its fields are showing,
        // so the command-line field must release focus / its on_input.
        // The MText preview also captures keystrokes (typing edits it), so the
        // command line must likewise release its on_input there.
        let dyn_capturing =
            (self.dyn_input && tab.active_cmd.is_some() && !tab.dyn_fields.is_empty())
                || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview)
                || self.text_inline.is_some();
        let command_line_overlay =
            iced::widget::container(self.command_line.view(allow_autocomplete, dyn_capturing))
                .width(Fill)
                .height(Fill)
                .align_x(iced::alignment::Horizontal::Center)
                .align_y(iced::alignment::Vertical::Bottom)
                .padding(iced::Padding {
                    top: 0.0,
                    right: 0.0,
                    bottom: 2.0,
                    left: 0.0,
                });

        let center_stack = iced::widget::stack![
            row![properties_el, viewport_stack].width(Fill).height(Fill),
            command_line_overlay,
        ]
        .width(Fill)
        .height(Fill);

        let main_ui = container({
            // Clean-screen mode drops the ribbon for a full-canvas view; the
            // status bar stays so the mode can be toggled back off.
            let mut col = column![];
            if !self.clean_screen {
                col = col.push(self.ribbon.view(
                    is_paper,
                    self.tabs[self.active_tab].history.undo_stack.len(),
                    self.tabs[self.active_tab].history.redo_stack.len(),
                ));
            }
            if self.show_file_tabs {
                col = col.push(doc_tab_bar(&self.tabs, self.active_tab));
            }
            col.push(center_stack)
                .push({
                    let is_model = tab.scene.current_layout == "Model";
                    let scale_pill_enabled = is_model
                        || tab.scene.active_viewport.is_some()
                        || tab.scene.has_selected_viewport();
                    // The cursor is tracked in local render space; re-add the
                    // model-space world offset so the readout shows true
                    // drawing coordinates (paper space carries no offset).
                    let cursor_coord = {
                        let lc = tab.last_cursor_world;
                        if is_model {
                            let wo = tab.scene.world_offset;
                            glam::Vec3::new(
                                lc.x + wo[0] as f32,
                                lc.y + wo[1] as f32,
                                lc.z + wo[2] as f32,
                            )
                        } else {
                            lc
                        }
                    };
                    self.status_bar.view(
                        &self.snapper,
                        self.snap_popup_open,
                        self.ortho_mode,
                        self.polar_mode,
                        self.polar_increment_deg,
                        self.show_grid,
                        self.dyn_input,
                        self.snapper.otrack_enabled,
                        tab.scene.layout_names(),
                        tab.scene.current_layout.clone(),
                        self.layout_rename_state.as_ref(),
                        tab.scene.first_viewport_scale(),
                        tab.scene.viewport_count(),
                        tab.scene.active_viewport.is_some(),
                        self.show_layout_tabs,
                        tab.scene.annotation_scale,
                        self.scale_popup_open,
                        scale_pill_enabled,
                        tab.scene.document.header.lineweight_display,
                        cursor_coord,
                        self.clean_screen,
                        tab.scene.document.header.insertion_units,
                        self.units_popup_open,
                        tab.scene.is_isolation_active(),
                        tab.scene.transparency_display,
                        self.quick_properties,
                        tab.scene.selection_filter_active(),
                        self.selection_cycling,
                        &self.statusbar_config,
                    )
                })
                .width(Fill)
                .height(Fill)
        })
        .style(|_: &Theme| container::Style {
            background: Some(Background::Color(Color {
                r: 0.11,
                g: 0.11,
                b: 0.11,
                a: 1.0,
            })),
            ..Default::default()
        })
        .width(Fill)
        .height(Fill);

        let snap_layer: Element<'_, Message> = if self.snap_popup_open {
            crate::ui::snap_popup::snap_popup_overlay(&self.snapper, 4.0)
        } else {
            iced::widget::Space::new().width(0).height(0).into()
        };

        let scale_layer: Element<'_, Message> = if self.scale_popup_open {
            let is_model = tab.scene.current_layout == "Model";
            crate::ui::scale_popup::scale_popup_overlay(
                is_model,
                tab.scene.annotation_scale,
                tab.scene.first_viewport_scale(),
                tab.scene.scale_list(),
            )
        } else {
            iced::widget::Space::new().width(0).height(0).into()
        };

        let statusbar_menu_layer: Element<'_, Message> = if self.statusbar_menu_open {
            crate::ui::statusbar_menu::statusbar_menu_overlay(&self.statusbar_config)
        } else {
            iced::widget::Space::new().width(0).height(0).into()
        };

        let units_layer: Element<'_, Message> = if self.units_popup_open {
            crate::ui::units_popup::units_popup_overlay(tab.scene.document.header.insertion_units)
        } else {
            iced::widget::Space::new().width(0).height(0).into()
        };

        let isolate_layer: Element<'_, Message> = if self.isolate_popup_open {
            crate::ui::isolate_popup::isolate_popup_overlay(
                !tab.scene.selected.is_empty(),
                tab.scene.is_isolation_active(),
            )
        } else {
            iced::widget::Space::new().width(0).height(0).into()
        };

        let sel_filter_layer: Element<'_, Message> = if self.selection_filter_popup_open {
            let types: Vec<String> = tab
                .scene
                .entity_type_names_in_layout()
                .into_iter()
                .map(|s| s.to_string())
                .collect();
            crate::ui::selection_filter_popup::selection_filter_popup_overlay(
                types,
                &tab.scene.selection_filter,
            )
        } else {
            iced::widget::Space::new().width(0).height(0).into()
        };

        let dropdown_layer: Element<'_, Message> = self
            .ribbon
            .dropdown_overlay(
                &history_dropdown_labels(&self.tabs[self.active_tab].history.undo_stack),
                &history_dropdown_labels(&self.tabs[self.active_tab].history.redo_stack),
            )
            .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into());

        let layout_ctx_layer: Element<'_, Message> = if let Some(name) = &self.layout_context_menu {
            layout_context_menu_overlay(name)
        } else {
            iced::widget::Space::new().width(0).height(0).into()
        };

        let qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect {
            let types = tab.scene.entity_type_names_in_layout();
            let properties = tab.scene.qselect_properties(state.type_filter.as_deref());
            qselect_overlay(state, &types, &properties)
        } else {
            iced::widget::Space::new().width(0).height(0).into()
        };

        let open_progress_layer: Element<'_, Message> = if let Some(p) = &self.opening {
            crate::ui::open_progress::view(p, iced::time::Instant::now())
        } else {
            iced::widget::Space::new().width(0).height(0).into()
        };

        stack![
            main_ui,
            self.app_menu.view(),
            snap_layer,
            scale_layer,
            statusbar_menu_layer,
            units_layer,
            isolate_layer,
            sel_filter_layer,
            dropdown_layer,
            layout_ctx_layer,
            qselect_layer,
            open_progress_layer,
        ]
        .into()
    }

    pub fn subscription(&self) -> Subscription<Message> {
        use iced::event;
        // Only request per-frame ticks while something on screen is animating
        // (currently just the open-progress indicator). Without this gate the
        // app burned 2-3% CPU continuously redrawing an unchanged view.
        // See #18.
        let needs_frames = self.opening.is_some();
        let frames = if needs_frames {
            window::frames().map(Message::Tick)
        } else {
            Subscription::none()
        };
        // While the command-line overlay is still displaying any
        // recently-pushed history entry, re-render every frame so the
        // entry disappears at the moment its visible window expires.
        // The subscription auto-stops once no entry is fresh enough
        // (typically within a few seconds of the last command).
        let history_tick = if self.command_line.has_visible_history() {
            window::frames().map(Message::Tick)
        } else {
            Subscription::none()
        };
        // While the cursor sits over a grip, request animation frames
        // so the multi-functional popup opens even when the user keeps
        // the mouse perfectly still — `ViewportMove` alone would never
        // fire again. Auto-stops once the hover clears or the popup is
        // already open.
        let grip_dwell = if self.grip_hover.is_some() && self.grip_popup.is_none() {
            window::frames().map(|_| Message::GripDwellTick)
        } else {
            Subscription::none()
        };
        // Blink the MText preview caret while the editor is open.
        let caret_blink = if self.mtext_editor.is_some() {
            iced::time::every(std::time::Duration::from_millis(530))
                .map(|_| Message::MTextCaretBlink)
        } else {
            Subscription::none()
        };
        iced::Subscription::batch([
            frames,
            history_tick,
            grip_dwell,
            caret_blink,
            event::listen_with(|ev, status, win_id| {
                use iced::event::Status;
                match ev {
                    iced::Event::Window(window::Event::CloseRequested) => {
                        Some(Message::WindowCloseRequested(win_id))
                    }
                    iced::Event::Window(window::Event::Closed) => {
                        Some(Message::OsWindowClosed(win_id))
                    }
                    iced::Event::Window(window::Event::Resized(sz)) => {
                        Some(Message::WindowResized(sz.width as f32, sz.height as f32))
                    }
                    iced::Event::Keyboard(keyboard::Event::ModifiersChanged(m)) => {
                        Some(Message::SetShiftDown(m.shift()))
                    }
                    iced::Event::Keyboard(keyboard::Event::KeyPressed {
                        key,
                        modifiers,
                        text,
                        ..
                    }) => {
                        let ctrl = modifiers.control();
                        let shift = modifiers.shift();
                        // Any key that produces a printable glyph types it,
                        // even when its logical key resolves to navigation
                        // (NumLock-on Numpad8 / Numpad2 arrive as
                        // ArrowUp / ArrowDown but still carry text "8" /
                        // "2"). Checked before the Arrow / history arms so
                        // those numpad digits aren't swallowed as history
                        // navigation. Whitespace / control text (Space,
                        // Enter, Tab) falls through to the named handlers.
                        if !ctrl && status == Status::Ignored {
                            if let Some(t) = text.as_deref() {
                                if !t.is_empty()
                                    && t.chars().all(|c| !c.is_control() && !c.is_whitespace())
                                {
                                    return Some(Message::CommandAppendChar(t.to_string()));
                                }
                            }
                        }
                        match key {
                            // Space is a literal space inside the MText preview
                            // but finalises a command otherwise; the handler
                            // decides based on editor state.
                            keyboard::Key::Named(keyboard::key::Named::Space)
                                if status == Status::Ignored =>
                            {
                                Some(Message::CommandSpace)
                            }
                            keyboard::Key::Named(keyboard::key::Named::Enter)
                                if status == Status::Ignored =>
                            {
                                Some(Message::CommandFinalize)
                            }
                            keyboard::Key::Named(keyboard::key::Named::Escape) => {
                                Some(Message::CommandEscape)
                            }
                            keyboard::Key::Named(keyboard::key::Named::Delete)
                                if status == Status::Ignored =>
                            {
                                Some(Message::DeleteSelected)
                            }
                            keyboard::Key::Named(keyboard::key::Named::Backspace)
                                if status == Status::Ignored =>
                            {
                                Some(Message::CommandBackspace)
                            }
                            keyboard::Key::Named(keyboard::key::Named::Tab)
                                if status == Status::Ignored =>
                            {
                                Some(Message::DynTabNext)
                            }
                            keyboard::Key::Named(keyboard::key::Named::ArrowUp)
                                if status == Status::Ignored =>
                            {
                                Some(Message::CommandHistoryPrev)
                            }
                            keyboard::Key::Named(keyboard::key::Named::ArrowDown)
                                if status == Status::Ignored =>
                            {
                                Some(Message::CommandHistoryNext)
                            }
                            // Caret movement in the MText preview (no-op
                            // otherwise; these arrows are unused elsewhere).
                            keyboard::Key::Named(keyboard::key::Named::ArrowLeft)
                                if status == Status::Ignored =>
                            {
                                Some(Message::MTextCaretMove(-1))
                            }
                            keyboard::Key::Named(keyboard::key::Named::ArrowRight)
                                if status == Status::Ignored =>
                            {
                                Some(Message::MTextCaretMove(1))
                            }
                            keyboard::Key::Named(keyboard::key::Named::F3) => {
                                Some(Message::ToggleSnapEnabled)
                            }
                            keyboard::Key::Named(keyboard::key::Named::F7) => {
                                Some(Message::ToggleGrid)
                            }
                            keyboard::Key::Named(keyboard::key::Named::F8) => {
                                Some(Message::ToggleOrtho)
                            }
                            keyboard::Key::Named(keyboard::key::Named::F9) => {
                                Some(Message::ToggleGridSnap)
                            }
                            keyboard::Key::Named(keyboard::key::Named::F10) => {
                                Some(Message::TogglePolar)
                            }
                            keyboard::Key::Named(keyboard::key::Named::F11) => {
                                Some(Message::ToggleOTrack)
                            }
                            keyboard::Key::Named(keyboard::key::Named::F12) => {
                                Some(Message::ToggleDynInput)
                            }
                            keyboard::Key::Character(c) if ctrl => match c.as_str() {
                                "n" => Some(Message::TabNew),
                                "o" => Some(Message::OpenFile),
                                "s" if !shift => Some(Message::SaveFile),
                                "s" if shift => Some(Message::SaveAs),
                                "z" if !shift => Some(Message::Undo),
                                "z" if shift => Some(Message::Redo),
                                "y" => Some(Message::Redo),
                                "c" => Some(Message::Command("COPYCLIP".to_string())),
                                "x" => Some(Message::Command("CUTCLIP".to_string())),
                                "v" => Some(Message::Command("PASTECLIP".to_string())),
                                _ => None,
                            },
                            // Printable glyphs are already handled by the
                            // text guard above the match; anything reaching
                            // here is a non-typing key we don't bind.
                            _ => None,
                        }
                    }
                    _ => None,
                }
            }),
        ])
    }

    pub(super) fn focus_cmd_input(&self) -> Task<Message> {
        iced::widget::operation::focus(iced::widget::Id::new(crate::ui::command_line::CMD_INPUT_ID))
    }
}

// ── Document tab bar ───────────────────────────────────────────────────────

pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Element<'a, Message> {
    const BAR_BG: Color = Color {
        r: 0.13,
        g: 0.13,
        b: 0.13,
        a: 1.0,
    };
    const TAB_ACTIVE: Color = Color {
        r: 0.22,
        g: 0.22,
        b: 0.22,
        a: 1.0,
    };
    const TAB_HOVER: Color = Color {
        r: 0.18,
        g: 0.18,
        b: 0.18,
        a: 1.0,
    };
    const TAB_INACTIVE: Color = Color {
        r: 0.13,
        g: 0.13,
        b: 0.13,
        a: 1.0,
    };
    const ACCENT: Color = Color {
        r: 0.20,
        g: 0.55,
        b: 0.90,
        a: 1.0,
    };
    const TEXT_ACTIVE: Color = Color::WHITE;
    const TEXT_INACTIVE: Color = Color {
        r: 0.60,
        g: 0.60,
        b: 0.60,
        a: 1.0,
    };
    const CLOSE_HOVER: Color = Color {
        r: 0.70,
        g: 0.22,
        b: 0.22,
        a: 1.0,
    };
    const BORDER_COLOR: Color = Color {
        r: 0.25,
        g: 0.25,
        b: 0.25,
        a: 1.0,
    };

    let mut bar = Row::new().spacing(0).align_y(iced::Center);

    for (idx, tab) in tabs.iter().enumerate() {
        let is_active = idx == active_tab;
        let name = crate::ui::text_util::elide(&tab.tab_display_name(), 24);
        let label = if tab.dirty {
            format!("● {}", name)
        } else {
            name
        };

        let title_btn = button(text(label).size(12))
            .on_press(Message::TabSwitch(idx))
            .padding([5, 12])
            .style(move |_: &Theme, status| button::Style {
                background: Some(Background::Color(match (is_active, status) {
                    (true, _) => TAB_ACTIVE,
                    (false, button::Status::Hovered) => TAB_HOVER,
                    _ => TAB_INACTIVE,
                })),
                text_color: if is_active {
                    TEXT_ACTIVE
                } else {
                    TEXT_INACTIVE
                },
                border: Border {
                    color: if is_active {
                        ACCENT
                    } else {
                        Color::TRANSPARENT
                    },
                    width: if is_active { 1.0 } else { 0.0 },
                    radius: 0.0.into(),
                },
                shadow: iced::Shadow::default(),
                snap: false,
            });

        // Start tab is fixed — no close button. Every other tab gets a close.
        let row_inner: Row<'_, Message> = if tab.is_start {
            row![title_btn].spacing(0).align_y(iced::Center)
        } else {
            let close_btn = button(text("×").size(11).color(Color {
                r: 0.55,
                g: 0.55,
                b: 0.55,
                a: 1.0,
            }))
            .on_press(Message::TabClose(idx))
            .padding([3, 5])
            .style(move |_: &Theme, status| button::Style {
                background: Some(Background::Color(match status {
                    button::Status::Hovered => CLOSE_HOVER,
                    _ => {
                        if is_active {
                            TAB_ACTIVE
                        } else {
                            TAB_INACTIVE
                        }
                    }
                })),
                border: Border {
                    radius: 3.0.into(),
                    ..Default::default()
                },
                ..Default::default()
            });
            row![title_btn, close_btn].spacing(0).align_y(iced::Center)
        };

        bar = bar.push(
            container(row_inner).style(move |_: &Theme| container::Style {
                border: Border {
                    color: if is_active {
                        BORDER_COLOR
                    } else {
                        Color::TRANSPARENT
                    },
                    width: if is_active { 1.0 } else { 0.0 },
                    radius: 0.0.into(),
                },
                ..Default::default()
            }),
        );
    }

    let new_btn = button(text("+").size(14).color(Color {
        r: 0.65,
        g: 0.65,
        b: 0.65,
        a: 1.0,
    }))
    .on_press(Message::TabNew)
    .padding([4, 10])
    .style(|_: &Theme, status| button::Style {
        background: Some(Background::Color(match status {
            button::Status::Hovered => TAB_HOVER,
            _ => Color::TRANSPARENT,
        })),
        border: Border {
            radius: 0.0.into(),
            ..Default::default()
        },
        ..Default::default()
    });

    bar = bar.push(new_btn);
    bar = bar.push(iced::widget::Space::new().width(Fill));

    container(bar)
        .style(|_: &Theme| container::Style {
            background: Some(Background::Color(BAR_BG)),
            border: Border {
                color: BORDER_COLOR,
                width: 1.0,
                radius: 0.0.into(),
            },
            ..Default::default()
        })
        .height(30)
        .width(Fill)
        .padding([0, 2])
        .into()
}

// ── Layout context-menu overlay ────────────────────────────────────────────

// ── Canvas-relative overlay positioning ────────────────────────────────────

/// Wraps `panel` in a column+row of `Space` widgets so it sits at
/// canvas-relative coordinates `(anchor.x, anchor.y)`. `panel` is wrapped
/// in `iced::widget::opaque` so mouse events on the panel itself do not
/// fall through to the viewport mouse area underneath; outside-click
/// dismissal is the caller's responsibility (handled via `ViewportLeftPress`
/// in `update.rs`, identical to how the multi-functional grip popup is
/// dismissed). Pushed into `viewport_stack` so the anchor is interpreted
/// in canvas-relative space, not window-relative.
fn position_canvas_overlay<'a>(
    anchor: iced::Point,
    panel: Element<'a, Message>,
) -> Element<'a, Message> {
    let ax = anchor.x.max(0.0);
    let ay = anchor.y.max(0.0);
    column![
        Space::new().height(iced::Length::Fixed(ay)),
        row![
            Space::new().width(iced::Length::Fixed(ax)),
            iced::widget::opaque(panel),
        ],
    ]
    .width(Fill)
    .height(Fill)
    .into()
}

// ── In-place MText editor overlay ───────────────────────────────────────────

/// Widget id for the MText editor's text area (focused when Edit mode opens).
pub(super) const MTEXT_TEXT_ID: &str = "mtext_editor_text";

/// Widget id for the in-place TEXT editor's input (focused when it opens).
pub(super) const TEXT_INLINE_ID: &str = "text_inline_input";

/// In-place single-line TEXT editor: a plain text-entry box (no formatting
/// toolbar), anchored at the insertion-point click. Enter commits; Esc cancels.
fn text_inline_overlay(
    ed: &super::text_inline::TextInlineState,
    canvas: (f32, f32),
) -> Element<'_, Message> {
    const PANEL_BG: Color = Color {
        r: 0.16,
        g: 0.16,
        b: 0.16,
        a: 0.98,
    };
    const BORDER: Color = Color {
        r: 0.40,
        g: 0.40,
        b: 0.40,
        a: 1.0,
    };

    let field = text_input("Text", &ed.value)
        .id(iced::widget::Id::new(TEXT_INLINE_ID))
        .on_input(Message::TextInlineInput)
        .on_submit(Message::TextInlineOk)
        .padding(6)
        .size(13)
        .width(iced::Length::Fixed(240.0));

    let panel = container(field)
        .style(move |_: &Theme| container::Style {
            background: Some(Background::Color(PANEL_BG)),
            border: Border {
                color: BORDER,
                width: 1.0,
                radius: 5.0.into(),
            },
            ..Default::default()
        })
        .padding(4);

    // Keep the box on-screen so its field stays clickable at the edges.
    const PANEL_W: f32 = 240.0 + 20.0;
    const PANEL_H: f32 = 46.0;
    let (cw, ch) = canvas;
    let anchor = iced::Point::new(
        (ed.screen_anchor.x - 6.0).clamp(0.0, (cw - PANEL_W).max(0.0)),
        (ed.screen_anchor.y - 18.0).clamp(0.0, (ch - PANEL_H).max(0.0)),
    );
    position_canvas_overlay(anchor, panel.into())
}

// Stroke-font families the renderer ships (LibreCAD LFF; see scene/lff.rs).
const MTEXT_FONTS: [&str; 10] = [
    "[Style default]",
    "Standard",
    "ISO",
    "Simplex",
    "RomanS",
    "RomanD",
    "ItalicC",
    "ScriptS",
    "GothGBT",
    "RomanC",
];
/// (label, ACI). 256 = ByLayer.
const MTEXT_COLORS: [(&str, u16); 8] = [
    ("ByLayer", 256),
    ("Red", 1),
    ("Yellow", 2),
    ("Green", 3),
    ("Cyan", 4),
    ("Blue", 5),
    ("Magenta", 6),
    ("White", 7),
];

/// Canvas program that renders the tessellated MText strokes inside the
/// editor's own preview area (never on the drawing). Strokes lie in the
/// world XY plane; the program fits + vertically flips them into the box.
const MTEXT_PREVIEW_PAD: f32 = 12.0;

struct MTextPreview {
    /// Disconnected polylines as (x, y) world points + colour (NaN-split done).
    segments: Vec<(Vec<(f32, f32)>, Color)>,
    /// Per-visible-character boxes (world frame) for click-to-select.
    boxes: Vec<crate::entities::text_support::GlyphBox>,
    /// Current selection as a visible-char range.
    sel: Option<(usize, usize)>,
    /// Caret position as a visible-char offset.
    caret: usize,
    /// Whether the caret is in its visible blink phase.
    caret_on: bool,
    /// World-space min corner (bbox) and pixels-per-world-unit scale.
    minx: f32,
    miny: f32,
    scale: f32,
    content_h: f32,
}

impl MTextPreview {
    /// Visible-char offset (0..=N) nearest the cursor point (bounds-local px).
    fn offset_at(&self, p: iced::Point) -> usize {
        if self.boxes.is_empty() {
            return 0;
        }
        let wx = self.minx + (p.x - MTEXT_PREVIEW_PAD) / self.scale;
        let wy = self.miny + (self.content_h - p.y - MTEXT_PREVIEW_PAD) / self.scale;
        let mut best = 0usize;
        let mut best_d = f32::MAX;
        for b in &self.boxes {
            let dx = if wx < b.xmin {
                b.xmin - wx
            } else if wx > b.xmax {
                wx - b.xmax
            } else {
                0.0
            };
            let dy = if wy < b.ymin {
                b.ymin - wy
            } else if wy > b.ymax {
                wy - b.ymax
            } else {
                0.0
            };
            let d = dy * 1000.0 + dx; // prefer the correct line first
            if d < best_d {
                best_d = d;
                best = b.vis;
                // After the glyph centre → caret sits after this char.
                if wx > (b.xmin + b.xmax) * 0.5 {
                    best = b.vis + 1;
                }
            }
        }
        best
    }
}

#[derive(Default)]
struct MTextPreviewState {
    dragging: bool,
}

impl iced::widget::canvas::Program<Message> for MTextPreview {
    type State = MTextPreviewState;

    fn update(
        &self,
        state: &mut MTextPreviewState,
        event: &iced::Event,
        bounds: iced::Rectangle,
        cursor: iced::mouse::Cursor,
    ) -> Option<iced::widget::canvas::Action<Message>> {
        use iced::mouse::{Button, Event as Me};
        use iced::widget::canvas::Action;
        use iced::Event;
        match event {
            Event::Mouse(Me::ButtonPressed(Button::Left)) => {
                if let Some(p) = cursor.position_in(bounds) {
                    state.dragging = true;
                    let off = self.offset_at(p);
                    return Some(Action::publish(Message::MTextSelStart(off)).and_capture());
                }
            }
            Event::Mouse(Me::CursorMoved { .. }) => {
                if state.dragging {
                    if let Some(p) = cursor.position_in(bounds) {
                        let off = self.offset_at(p);
                        return Some(Action::publish(Message::MTextSelTo(off)));
                    }
                }
            }
            Event::Mouse(Me::ButtonReleased(Button::Left)) => {
                if state.dragging {
                    state.dragging = false;
                    return Some(Action::capture());
                }
            }
            _ => {}
        }
        None
    }

    fn draw(
        &self,
        _state: &MTextPreviewState,
        renderer: &iced::Renderer,
        _theme: &Theme,
        bounds: iced::Rectangle,
        _cursor: iced::mouse::Cursor,
    ) -> Vec<iced::widget::canvas::Geometry> {
        use iced::widget::canvas::{Frame, Path, Stroke};
        let mut frame = Frame::new(renderer, bounds.size());
        let pad = MTEXT_PREVIEW_PAD;
        // Draw at the real size; flip Y (world up → screen down).
        let map = |x: f32, y: f32| {
            iced::Point::new(
                pad + (x - self.minx) * self.scale,
                self.content_h - (pad + (y - self.miny) * self.scale),
            )
        };
        // Selection highlight behind the glyphs.
        if let Some((a, b)) = self.sel {
            for bx in &self.boxes {
                if bx.vis >= a && bx.vis < b {
                    let p0 = map(bx.xmin, bx.ymax);
                    let p1 = map(bx.xmax, bx.ymin);
                    let rect = Path::rectangle(
                        iced::Point::new(p0.x.min(p1.x), p0.y.min(p1.y)),
                        iced::Size::new((p1.x - p0.x).abs(), (p1.y - p0.y).abs()),
                    );
                    frame.fill(
                        &rect,
                        Color {
                            r: 0.20,
                            g: 0.42,
                            b: 0.72,
                            a: 0.45,
                        },
                    );
                }
            }
        }
        for (seg, col) in &self.segments {
            if seg.len() < 2 {
                continue;
            }
            let path = Path::new(|p| {
                p.move_to(map(seg[0].0, seg[0].1));
                for &(x, y) in &seg[1..] {
                    p.line_to(map(x, y));
                }
            });
            frame.stroke(&path, Stroke::default().with_color(*col).with_width(1.4));
        }
        // Caret — a vertical bar at the caret's glyph boundary, shown when the
        // selection is empty (a plain text cursor).
        // Caret is shown only when the selection is empty and the blink is in
        // its visible phase.
        let collapsed = self.caret_on && self.sel.map(|(a, b)| a == b).unwrap_or(true);
        if collapsed && self.boxes.is_empty() {
            // Empty text: show a caret at the top-left so the user can type.
            let path = Path::new(|p| {
                p.move_to(iced::Point::new(MTEXT_PREVIEW_PAD, MTEXT_PREVIEW_PAD));
                p.line_to(iced::Point::new(
                    MTEXT_PREVIEW_PAD,
                    (MTEXT_PREVIEW_PAD + 22.0).min(self.content_h),
                ));
            });
            frame.stroke(
                &path,
                Stroke::default()
                    .with_color(Color {
                        r: 0.95,
                        g: 0.95,
                        b: 0.55,
                        a: 1.0,
                    })
                    .with_width(1.5),
            );
        } else if collapsed {
            let bar = if let Some(b) = self.boxes.iter().find(|b| b.vis == self.caret) {
                Some((b.xmin, b.ymin, b.ymax)) // left edge of the caret's glyph
            } else if self.caret > 0 {
                self.boxes
                    .iter()
                    .find(|b| b.vis == self.caret - 1)
                    .map(|b| (b.xmax, b.ymin, b.ymax)) // after the last glyph
            } else {
                self.boxes.first().map(|b| (b.xmin, b.ymin, b.ymax))
            };
            if let Some((cx, y0, y1)) = bar {
                let p0 = map(cx, y0);
                let p1 = map(cx, y1);
                let path = Path::new(|p| {
                    p.move_to(p0);
                    p.line_to(p1);
                });
                frame.stroke(
                    &path,
                    Stroke::default()
                        .with_color(Color {
                            r: 0.95,
                            g: 0.95,
                            b: 0.55,
                            a: 1.0,
                        })
                        .with_width(1.5),
                );
            }
        }
        vec![frame.into_geometry()]
    }
}

/// Split every preview WireModel into finite (x, y) polyline runs, each
/// carrying its wire's colour so inline `\C` / the colour dropdown shows.
fn mtext_preview_segments(
    ed: &super::mtext_editor::MTextEditorState,
) -> Vec<(Vec<(f32, f32)>, Color)> {
    let mut out: Vec<(Vec<(f32, f32)>, Color)> = Vec::new();
    for w in &ed.preview_wires {
        let col = Color {
            r: w.color[0],
            g: w.color[1],
            b: w.color[2],
            a: 1.0,
        };
        let mut run: Vec<(f32, f32)> = Vec::new();
        for p in &w.points {
            if p[0].is_finite() && p[1].is_finite() {
                run.push((p[0], p[1]));
            } else if !run.is_empty() {
                out.push((std::mem::take(&mut run), col));
            }
        }
        if !run.is_empty() {
            out.push((run, col));
        }
    }
    out
}

fn mtext_editor_overlay<'a>(
    ed: &'a super::mtext_editor::MTextEditorState,
    styles: Vec<String>,
    canvas_size: (f32, f32),
) -> Element<'a, Message> {
    use super::mtext_editor::{JustifyChoice, MTextFmt, ParaAlign};
    use iced::widget::{canvas, svg, text_editor};

    const PANEL_BG: Color = Color {
        r: 0.16,
        g: 0.16,
        b: 0.16,
        a: 0.98,
    };
    const BORDER: Color = Color {
        r: 0.40,
        g: 0.40,
        b: 0.40,
        a: 1.0,
    };
    const TEXT_COL: Color = Color {
        r: 0.88,
        g: 0.88,
        b: 0.88,
        a: 1.0,
    };
    const FIELD_BG: Color = Color {
        r: 0.12,
        g: 0.12,
        b: 0.12,
        a: 1.0,
    };

    let btn_style = |_: &Theme, status: button::Status| button::Style {
        background: Some(Background::Color(match status {
            button::Status::Hovered | button::Status::Pressed => Color {
                r: 0.28,
                g: 0.40,
                b: 0.55,
                a: 1.0,
            },
            _ => Color {
                r: 0.22,
                g: 0.22,
                b: 0.22,
                a: 1.0,
            },
        })),
        text_color: TEXT_COL,
        border: Border {
            color: BORDER,
            width: 1.0,
            radius: 3.0.into(),
        },
        shadow: iced::Shadow::default(),
        snap: false,
    };
    let icon_btn = move |bytes: &'static [u8], msg: Message| -> Element<'static, Message> {
        button(svg(svg::Handle::from_memory(bytes)).width(18).height(18))
            .on_press(msg)
            .padding(3)
            .style(btn_style)
            .into()
    };
    let lbl = |s: &'static str| text(s).size(11).color(TEXT_COL);
    let small_input = |placeholder: &'static str,
                       val: &str,
                       on: fn(String) -> Message,
                       w: f32|
     -> Element<'static, Message> {
        text_input(placeholder, val)
            .on_input(on)
            .width(iced::Length::Fixed(w))
            .padding(3)
            .size(12)
            .into()
    };

    // ── Row 1: style / font / height · format icons · colour ──────────────
    let style_opts: Vec<String> = if styles.is_empty() {
        vec!["Standard".to_string()]
    } else {
        styles
    };
    let style_pl = pick_list(style_opts, Some(ed.style.clone()), Message::MTextStyle)
        .text_size(11)
        .width(iced::Length::Fixed(96.0));
    let font_sel = if ed.font.trim().is_empty() {
        "[Style default]".to_string()
    } else {
        ed.font.clone()
    };
    let font_pl = pick_list(
        MTEXT_FONTS
            .iter()
            .map(|s| s.to_string())
            .collect::<Vec<_>>(),
        Some(font_sel),
        Message::MTextFont,
    )
    .text_size(11)
    .width(iced::Length::Fixed(120.0));
    let color_sel = MTEXT_COLORS
        .iter()
        .find(|(_, a)| *a == ed.color_aci)
        .map(|(n, _)| n.to_string())
        .unwrap_or_else(|| "ByLayer".to_string());
    let color_pl = pick_list(
        MTEXT_COLORS
            .iter()
            .map(|(n, _)| n.to_string())
            .collect::<Vec<_>>(),
        Some(color_sel),
        |name: String| {
            let aci = MTEXT_COLORS
                .iter()
                .find(|(n, _)| *n == name)
                .map(|(_, a)| *a)
                .unwrap_or(256);
            Message::MTextColor(aci)
        },
    )
    .text_size(11)
    .width(iced::Length::Fixed(96.0));

    let row1 = row![
        style_pl,
        font_pl,
        small_input("2.5", &ed.height, Message::MTextHeight, 64.0),
        iced::widget::Space::new().width(6),
        icon_btn(
            include_bytes!("../../assets/icons/mt_bold.svg"),
            Message::MTextFmt(MTextFmt::Bold)
        ),
        icon_btn(
            include_bytes!("../../assets/icons/mt_italic.svg"),
            Message::MTextFmt(MTextFmt::Italic)
        ),
        icon_btn(
            include_bytes!("../../assets/icons/mt_underline.svg"),
            Message::MTextFmt(MTextFmt::Underline)
        ),
        icon_btn(
            include_bytes!("../../assets/icons/mt_overline.svg"),
            Message::MTextFmt(MTextFmt::Overline)
        ),
        icon_btn(
            include_bytes!("../../assets/icons/mt_strike.svg"),
            Message::MTextFmt(MTextFmt::Strike)
        ),
        icon_btn(
            include_bytes!("../../assets/icons/mt_upper.svg"),
            Message::MTextFmt(MTextFmt::Uppercase)
        ),
        icon_btn(
            include_bytes!("../../assets/icons/mt_lower.svg"),
            Message::MTextFmt(MTextFmt::Lowercase)
        ),
        iced::widget::Space::new().width(Fill),
        color_pl,
    ]
    .spacing(4)
    .align_y(iced::Alignment::Center);

    // ── Row 2: oblique / width / char-spacing · align · line spacing · OK ─
    let justify = pick_list(
        JustifyChoice::ALL,
        Some(JustifyChoice(ed.attachment)),
        |c| Message::MTextJustify(c.0),
    )
    .text_size(11)
    .width(iced::Length::Fixed(112.0));
    let row2 = row![
        lbl("O"),
        small_input("0", &ed.oblique, Message::MTextOblique, 48.0),
        lbl("W"),
        small_input("1", &ed.width, Message::MTextWidth, 48.0),
        lbl("◊"),
        small_input("0", &ed.char_space, Message::MTextCharSpace, 48.0),
        iced::widget::Space::new().width(6),
        icon_btn(
            include_bytes!("../../assets/icons/mt_align_left.svg"),
            Message::MTextAlign(ParaAlign::Left)
        ),
        icon_btn(
            include_bytes!("../../assets/icons/mt_align_center.svg"),
            Message::MTextAlign(ParaAlign::Center)
        ),
        icon_btn(
            include_bytes!("../../assets/icons/mt_align_right.svg"),
            Message::MTextAlign(ParaAlign::Right)
        ),
        icon_btn(
            include_bytes!("../../assets/icons/mt_align_justify.svg"),
            Message::MTextAlign(ParaAlign::Justify)
        ),
        iced::widget::Space::new().width(6),
        justify,
        lbl("LS"),
        button(lbl("1"))
            .on_press(Message::MTextLineSpacing(1.0))
            .padding(3)
            .style(btn_style),
        button(lbl("1.5"))
            .on_press(Message::MTextLineSpacing(1.5))
            .padding(3)
            .style(btn_style),
        button(lbl("2"))
            .on_press(Message::MTextLineSpacing(2.0))
            .padding(3)
            .style(btn_style),
        iced::widget::Space::new().width(Fill),
        icon_btn(
            include_bytes!("../../assets/icons/mt_ok.svg"),
            Message::MTextOk
        ),
        icon_btn(
            include_bytes!("../../assets/icons/mt_cancel.svg"),
            Message::MTextCancel
        ),
    ]
    .spacing(4)
    .align_y(iced::Alignment::Center);

    // ── Segmented Edit | Preview toggle (between toolbar and body) ────────
    let seg_btn =
        move |label: &'static str, active: bool, on: Message| -> Element<'static, Message> {
            button(text(label).size(12).color(if active {
                Color::WHITE
            } else {
                Color {
                    r: 0.80,
                    g: 0.80,
                    b: 0.80,
                    a: 1.0,
                }
            }))
            .on_press(on)
            .padding([4, 14])
            .style(move |_: &Theme, _| button::Style {
                background: Some(Background::Color(if active {
                    Color {
                        r: 0.20,
                        g: 0.42,
                        b: 0.72,
                        a: 1.0,
                    }
                } else {
                    Color {
                        r: 0.20,
                        g: 0.20,
                        b: 0.20,
                        a: 1.0,
                    }
                })),
                text_color: TEXT_COL,
                border: Border {
                    color: BORDER,
                    width: 1.0,
                    radius: 4.0.into(),
                },
                shadow: iced::Shadow::default(),
                snap: false,
            })
            .into()
        };
    let toggle = container(
        row![
            seg_btn("Edit", !ed.show_preview, Message::MTextShowPreview(false)),
            seg_btn("Preview", ed.show_preview, Message::MTextShowPreview(true)),
        ]
        .spacing(0),
    )
    .padding([6, 0]);

    // ── Body: toggles between raw code input and rendered preview ────────
    const VIEW_H: f32 = 150.0;
    let body: Element<'a, Message> = if ed.show_preview {
        let segments = mtext_preview_segments(ed);
        let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
        for (seg, _) in &segments {
            for &(x, y) in seg {
                minx = minx.min(x);
                miny = miny.min(y);
                maxx = maxx.max(x);
                maxy = maxy.max(y);
            }
        }
        // Include glyph boxes so all-whitespace / box-only lines still anchor
        // the transform (hit-testing relies on minx/miny).
        for b in &ed.glyph_boxes {
            minx = minx.min(b.xmin);
            miny = miny.min(b.ymin);
            maxx = maxx.max(b.xmax);
            maxy = maxy.max(b.ymax);
        }
        let h_unit = ed.height_value() as f32;
        // Real text size: fixed pixels per em so more/taller text grows the
        // canvas (and scrolls) instead of shrinking to fit.
        let scale = (22.0 / h_unit.max(1e-3)).clamp(2.0, 600.0);
        let content_h = if maxx >= minx {
            ((maxy - miny) * scale + 2.0 * MTEXT_PREVIEW_PAD).max(40.0)
        } else {
            40.0
        };
        let prog = MTextPreview {
            segments,
            boxes: ed.glyph_boxes.clone(),
            sel: ed.sel,
            caret: ed.caret,
            caret_on: ed.caret_blink_on,
            minx,
            miny,
            scale,
            content_h,
        };
        let cv = canvas(prog)
            .width(Fill)
            .height(iced::Length::Fixed(content_h));
        container(iced::widget::scrollable(cv).height(iced::Length::Fixed(VIEW_H)))
            .style(move |_: &Theme| container::Style {
                background: Some(Background::Color(FIELD_BG)),
                border: Border {
                    color: BORDER,
                    width: 1.0,
                    radius: 3.0.into(),
                },
                ..Default::default()
            })
            .padding(2)
            .width(Fill)
            .into()
    } else {
        text_editor(&ed.content)
            .id(iced::widget::Id::new(MTEXT_TEXT_ID))
            .on_action(Message::MTextEdit)
            .height(iced::Length::Fixed(VIEW_H))
            .padding(6)
            .size(13)
            .into()
    };

    let panel = container(column![row1, row2, toggle, body].spacing(5))
        .style(move |_: &Theme| container::Style {
            background: Some(Background::Color(PANEL_BG)),
            border: Border {
                color: BORDER,
                width: 1.0,
                radius: 5.0.into(),
            },
            ..Default::default()
        })
        .padding(6)
        .width(iced::Length::Fixed(640.0));

    // Keep the whole panel on-screen: clamp the anchor so it never spills past
    // the right/bottom edge (where its toolbar buttons would be unclickable).
    // Width is fixed; height is the toolbar rows + the fixed VIEW_H body.
    const PANEL_W: f32 = 640.0 + 14.0; // fixed width + padding/border
    const PANEL_H: f32 = VIEW_H + 150.0; // body + toolbars/toggle/padding
    let (cw, ch) = canvas_size;
    let anchor = iced::Point::new(
        (ed.screen_anchor.x - 10.0).clamp(0.0, (cw - PANEL_W).max(0.0)),
        (ed.screen_anchor.y - 90.0).clamp(0.0, (ch - PANEL_H).max(0.0)),
    );
    position_canvas_overlay(anchor, panel.into())
}

// ── Viewport right-click context menu ──────────────────────────────────────

fn viewport_context_menu_overlay(
    pos: iced::Point,
    has_cmd: bool,
    has_selection: bool,
    isolation_active: bool,
    last_cmds: Vec<String>,
    draworder_open: bool,
) -> Element<'static, Message> {
    const MENU_BG: Color = Color {
        r: 0.17,
        g: 0.17,
        b: 0.17,
        a: 1.0,
    };
    const MENU_BORDER: Color = Color {
        r: 0.35,
        g: 0.35,
        b: 0.35,
        a: 1.0,
    };
    const ITEM_HOVER: Color = Color {
        r: 0.25,
        g: 0.45,
        b: 0.70,
        a: 1.0,
    };
    const TEXT_COL: Color = Color {
        r: 0.88,
        g: 0.88,
        b: 0.88,
        a: 1.0,
    };
    const SEP_COL: Color = Color {
        r: 0.30,
        g: 0.30,
        b: 0.30,
        a: 1.0,
    };

    let item = |label: String, msg: Message| -> Element<'static, Message> {
        button(text(label).size(12).color(TEXT_COL))
            .on_press(msg)
            .style(|_: &Theme, status| button::Style {
                background: Some(Background::Color(match status {
                    button::Status::Hovered | button::Status::Pressed => ITEM_HOVER,
                    _ => Color::TRANSPARENT,
                })),
                text_color: TEXT_COL,
                border: Border::default(),
                shadow: iced::Shadow::default(),
                snap: false,
            })
            .padding([4, 12])
            .width(Fill)
            .into()
    };

    let sep = || -> Element<'static, Message> {
        container(iced::widget::Space::new().width(Fill).height(1))
            .style(move |_: &Theme| container::Style {
                background: Some(Background::Color(SEP_COL)),
                ..Default::default()
            })
            .width(Fill)
            .height(1)
            .padding([0, 6])
            .into()
    };

    // Indented variant for sub-menu rows (e.g. Draw Order children).
    let subitem = |label: String, msg: Message| -> Element<'static, Message> {
        button(text(label).size(12).color(TEXT_COL))
            .on_press(msg)
            .style(|_: &Theme, status| button::Style {
                background: Some(Background::Color(match status {
                    button::Status::Hovered | button::Status::Pressed => ITEM_HOVER,
                    _ => Color::TRANSPARENT,
                })),
                text_color: TEXT_COL,
                border: Border::default(),
                shadow: iced::Shadow::default(),
                snap: false,
            })
            .padding(iced::Padding {
                top: 4.0,
                right: 12.0,
                bottom: 4.0,
                left: 26.0,
            })
            .width(Fill)
            .into()
    };

    let mut items: Vec<Element<'static, Message>> = Vec::new();

    if has_cmd {
        items.push(item("Cancel".to_string(), Message::CommandEscape));
        items.push(item("Enter".to_string(), Message::CommandFinalize));
    } else {
        if !last_cmds.is_empty() {
            let last = last_cmds[0].clone();
            items.push(item(
                format!("Repeat {last}"),
                Message::Command(last.to_uppercase()),
            ));
            if last_cmds.len() > 1 {
                for cmd in last_cmds.iter().skip(1) {
                    let c = cmd.clone();
                    items.push(item(c.clone(), Message::Command(c.to_uppercase())));
                }
            }
            items.push(sep());
        }
        if has_selection {
            items.push(item("Delete".to_string(), Message::DeleteSelected));
            items.push(item(
                "Move".to_string(),
                Message::Command("MOVE".to_string()),
            ));
            items.push(item(
                "Copy".to_string(),
                Message::Command("COPY".to_string()),
            ));
            items.push(sep());
            let arrow = if draworder_open {
                "Draw Order  \u{25be}"
            } else {
                "Draw Order  \u{25b8}"
            };
            items.push(item(arrow.to_string(), Message::DrawOrderSubmenuToggle));
            if draworder_open {
                items.push(subitem(
                    "Bring to Front".to_string(),
                    Message::Command("DRAWORDER F".to_string()),
                ));
                items.push(subitem(
                    "Send to Back".to_string(),
                    Message::Command("DRAWORDER B".to_string()),
                ));
                items.push(subitem(
                    "Bring Above Object".to_string(),
                    Message::DrawOrderPickRef(true),
                ));
                items.push(subitem(
                    "Send Under Object".to_string(),
                    Message::DrawOrderPickRef(false),
                ));
            }
            items.push(sep());
            items.push(item(
                "Isolate Objects".to_string(),
                Message::Command("ISOLATEOBJECTS".to_string()),
            ));
            items.push(item(
                "Hide Objects".to_string(),
                Message::Command("HIDEOBJECTS".to_string()),
            ));
            items.push(sep());
            items.push(item("Select Similar".to_string(), Message::SelectSimilar));
        }
        if isolation_active {
            items.push(item(
                "End Object Isolation".to_string(),
                Message::Command("UNISOLATEOBJECTS".to_string()),
            ));
        }
        items.push(item(
            "Select All".to_string(),
            Message::Command("SELECTALL".to_string()),
        ));
        items.push(item("Quick Select...".to_string(), Message::QSelectOpen));
        items.push(item(
            "Zoom Extents".to_string(),
            Message::Command("ZOOM EXTENTS".to_string()),
        ));
    }

    let menu_col = column(items).spacing(0).width(iced::Length::Fixed(180.0));

    let menu = container(menu_col)
        .style(move |_: &Theme| container::Style {
            background: Some(Background::Color(MENU_BG)),
            border: Border {
                color: MENU_BORDER,
                width: 1.0,
                radius: 4.0.into(),
            },
            ..Default::default()
        })
        .padding([4, 0])
        .width(iced::Length::Fixed(180.0));

    position_canvas_overlay(pos, menu.into())
}

/// A small right-click context menu rendered above the status bar.
/// The `name` is the layout tab that was right-clicked.
fn layout_context_menu_overlay(name: &str) -> Element<'_, Message> {
    const MENU_BG: Color = Color {
        r: 0.17,
        g: 0.17,
        b: 0.17,
        a: 1.0,
    };
    const MENU_BORDER: Color = Color {
        r: 0.35,
        g: 0.35,
        b: 0.35,
        a: 1.0,
    };
    const ITEM_HOVER: Color = Color {
        r: 0.25,
        g: 0.45,
        b: 0.70,
        a: 1.0,
    };
    const TEXT_COLOR: Color = Color {
        r: 0.88,
        g: 0.88,
        b: 0.88,
        a: 1.0,
    };

    let item = |label: &'static str, msg: Message| {
        button(text(label).size(12).color(TEXT_COLOR))
            .on_press(msg)
            .style(|_: &Theme, status| button::Style {
                background: Some(Background::Color(match status {
                    button::Status::Hovered | button::Status::Pressed => ITEM_HOVER,
                    _ => Color::TRANSPARENT,
                })),
                text_color: TEXT_COLOR,
                border: Border::default(),
                shadow: iced::Shadow::default(),
                snap: false,
            })
            .padding([4, 12])
            .width(Fill)
    };

    let rename_name = name.to_string();
    let delete_name = name.to_string();

    let menu = container(
        column![
            item("Rename", Message::LayoutRenameStart(rename_name)),
            item("Delete", Message::LayoutDelete(delete_name)),
        ]
        .spacing(0)
        .width(160),
    )
    .style(move |_: &Theme| container::Style {
        background: Some(Background::Color(MENU_BG)),
        border: Border {
            color: MENU_BORDER,
            width: 1.0,
            radius: 4.0.into(),
        },
        ..Default::default()
    })
    .padding([4, 0]);

    // Click-catcher fills the whole screen to close the menu when clicking outside.
    let catcher = mouse_area(
        container(iced::widget::Space::new().width(Fill).height(Fill))
            .width(Fill)
            .height(Fill),
    )
    .on_press(Message::LayoutContextMenuClose)
    .on_right_press(Message::LayoutContextMenuClose);

    // Position the menu above the status bar at the left.
    let positioned = container(menu)
        .align_bottom(Fill)
        .align_left(Fill)
        .padding(iced::Padding {
            top: 0.0,
            right: 0.0,
            bottom: 30.0,
            left: 8.0,
        });

    stack![catcher, positioned].into()
}

// ── Quick Select panel ─────────────────────────────────────────────────────

const QSELECT_ANY_TYPE: &str = "(Any type)";
const QSELECT_ANY_PROP: &str = "(Any property)";

/// Floating panel for the Quick Select feature. Single-row filter:
/// object type → property → operator → value, plus an "Append to current
/// selection" checkbox. The property dropdown is type-aware — Common
/// properties (Layer, Color, Linetype, Lineweight) are always shown;
/// picking a specific Object type adds that type's `geometry_properties`
/// fields (Start X, Length, Radius, …) so type-specific filtering works.
fn qselect_overlay<'a>(
    state: &'a crate::app::QSelectState,
    types: &[&'static str],
    properties: &[(String, String)],
) -> Element<'a, Message> {
    use iced::widget::{checkbox, pick_list};
    const BG: Color = Color {
        r: 0.12,
        g: 0.12,
        b: 0.12,
        a: 0.98,
    };
    const BORDER: Color = Color {
        r: 0.35,
        g: 0.35,
        b: 0.35,
        a: 1.0,
    };
    const TEXT: Color = Color {
        r: 0.88,
        g: 0.88,
        b: 0.88,
        a: 1.0,
    };
    const BTN_OK: Color = Color {
        r: 0.22,
        g: 0.42,
        b: 0.68,
        a: 1.0,
    };
    const BTN_OK_HOV: Color = Color {
        r: 0.30,
        g: 0.52,
        b: 0.80,
        a: 1.0,
    };
    const BTN_BG: Color = Color {
        r: 0.22,
        g: 0.22,
        b: 0.22,
        a: 1.0,
    };
    const BTN_HOV: Color = Color {
        r: 0.30,
        g: 0.30,
        b: 0.30,
        a: 1.0,
    };

    let mut type_options: Vec<String> = vec![QSELECT_ANY_TYPE.to_string()];
    type_options.extend(types.iter().map(|s| (*s).to_string()));

    let mut prop_options: Vec<crate::app::QSelectPropertyChoice> =
        vec![crate::app::QSelectPropertyChoice {
            field: String::new(),
            label: QSELECT_ANY_PROP.to_string(),
        }];
    prop_options.extend(properties.iter().map(|(field, label)| {
        crate::app::QSelectPropertyChoice {
            field: field.clone(),
            label: label.clone(),
        }
    }));

    let op_options: Vec<crate::app::QSelectOp> = vec![
        crate::app::QSelectOp::Eq,
        crate::app::QSelectOp::Neq,
        crate::app::QSelectOp::Gt,
        crate::app::QSelectOp::Lt,
        crate::app::QSelectOp::Any,
    ];

    let type_sel = state
        .type_filter
        .clone()
        .unwrap_or_else(|| QSELECT_ANY_TYPE.to_string());
    let prop_sel = state
        .property
        .clone()
        .unwrap_or(crate::app::QSelectPropertyChoice {
            field: String::new(),
            label: QSELECT_ANY_PROP.to_string(),
        });

    // The value field is disabled (visually de-emphasised; we still
    // render the same widget) when no property is picked or the
    // operator is "*Any value" — both of those skip the value test.
    let value_enabled =
        state.property.is_some() && !matches!(state.operator, crate::app::QSelectOp::Any);

    let label = |s: &'static str| {
        text(s)
            .size(12)
            .color(TEXT)
            .width(iced::Length::Fixed(90.0))
    };

    let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| {
        button(text(lbl).size(12).color(TEXT))
            .on_press(msg)
            .style(move |_: &Theme, st| button::Style {
                background: Some(Background::Color(
                    if matches!(st, button::Status::Hovered | button::Status::Pressed) {
                        hov
                    } else {
                        base
                    },
                )),
                text_color: TEXT,
                border: Border {
                    color: BORDER,
                    width: 1.0,
                    radius: 4.0.into(),
                },
                ..Default::default()
            })
            .padding([4, 14])
    };

    let mut value_input = text_input("", &state.value).size(12);
    if value_enabled {
        value_input = value_input.on_input(Message::QSelectSetValue);
    }

    let panel_body = column![
        text("Quick Select").size(14).color(TEXT),
        Space::new().height(10),
        row![
            label("Object type:"),
            pick_list(type_options, Some(type_sel), |s: String| {
                if s == QSELECT_ANY_TYPE {
                    Message::QSelectSetType(None)
                } else {
                    Message::QSelectSetType(Some(s))
                }
            })
            .width(Fill),
        ]
        .align_y(iced::Alignment::Center)
        .spacing(8),
        Space::new().height(6),
        row![
            label("Property:"),
            pick_list(
                prop_options,
                Some(prop_sel),
                |p: crate::app::QSelectPropertyChoice| {
                    if p.field.is_empty() {
                        Message::QSelectSetProperty(None)
                    } else {
                        Message::QSelectSetProperty(Some(p))
                    }
                }
            )
            .width(Fill),
        ]
        .align_y(iced::Alignment::Center)
        .spacing(8),
        Space::new().height(6),
        row![
            label("Operator:"),
            pick_list(
                op_options,
                Some(state.operator),
                Message::QSelectSetOperator
            )
            .width(Fill),
        ]
        .align_y(iced::Alignment::Center)
        .spacing(8),
        Space::new().height(6),
        row![label("Value:"), value_input,]
            .align_y(iced::Alignment::Center)
            .spacing(8),
        Space::new().height(10),
        row![
            checkbox(state.append)
                .on_toggle(Message::QSelectSetAppend)
                .size(14),
            Space::new().width(6),
            text("Append to current selection").size(12).color(TEXT),
        ]
        .align_y(iced::Alignment::Center),
        Space::new().height(14),
        row![
            Space::new().width(Fill),
            btn("Cancel", Message::QSelectClose, BTN_BG, BTN_HOV),
            Space::new().width(8),
            btn("Apply", Message::QSelectApply, BTN_OK, BTN_OK_HOV),
        ]
        .align_y(iced::Alignment::Center),
    ]
    .spacing(0);

    let panel = container(panel_body)
        .padding(16)
        .width(iced::Length::Fixed(400.0))
        .style(|_: &Theme| container::Style {
            background: Some(Background::Color(BG)),
            border: Border {
                color: BORDER,
                width: 1.0,
                radius: 6.0.into(),
            },
            ..Default::default()
        });

    // Outside-click catcher — fills the whole screen, sits below the
    // panel. The panel itself is rendered above and absorbs its own
    // clicks via standard widget event handling.
    let catcher = mouse_area(
        container(iced::widget::Space::new().width(Fill).height(Fill))
            .width(Fill)
            .height(Fill),
    )
    .on_press(Message::QSelectClose)
    .on_right_press(Message::QSelectClose);

    let centered = container(iced::widget::opaque(panel)).center(Fill);

    stack![catcher, centered].into()
}

/// Content for the floating "Unsaved Changes" OS window.
const SAVE_FORMAT_OPTIONS: &[&str] = &[
    "DWG 2018", "DWG 2013", "DWG 2010", "DWG 2007", "DWG 2004", "DWG 2000", "DWG R14", "DXF 2018",
    "DXF 2013", "DXF 2010", "DXF 2007", "DXF 2004", "DXF 2000", "DXF R14",
];

fn save_as_dialog_window<'a>(
    filename: &'a str,
    folder: &'a std::path::Path,
    entries: &'a [(String, bool, std::path::PathBuf)],
    format: &'a str,
) -> Element<'a, Message> {
    const BG: Color = Color {
        r: 0.15,
        g: 0.15,
        b: 0.17,
        a: 1.0,
    };
    const LIST_BG: Color = Color {
        r: 0.11,
        g: 0.11,
        b: 0.13,
        a: 1.0,
    };
    const BORDER: Color = Color {
        r: 0.32,
        g: 0.32,
        b: 0.36,
        a: 1.0,
    };
    const TEXT: Color = Color {
        r: 0.90,
        g: 0.90,
        b: 0.90,
        a: 1.0,
    };
    const DIM: Color = Color {
        r: 0.58,
        g: 0.58,
        b: 0.62,
        a: 1.0,
    };
    const INPUT_BG: Color = Color {
        r: 0.10,
        g: 0.10,
        b: 0.12,
        a: 1.0,
    };
    const BTN_OK: Color = Color {
        r: 0.20,
        g: 0.46,
        b: 0.80,
        a: 1.0,
    };
    const BTN_HOV: Color = Color {
        r: 0.26,
        g: 0.55,
        b: 0.92,
        a: 1.0,
    };
    const BTN_GREY: Color = Color {
        r: 0.26,
        g: 0.26,
        b: 0.29,
        a: 1.0,
    };
    const BTN_GHOV: Color = Color {
        r: 0.34,
        g: 0.34,
        b: 0.38,
        a: 1.0,
    };
    const DIR_COL: Color = Color {
        r: 0.75,
        g: 0.85,
        b: 1.00,
        a: 1.0,
    };
    const FILE_COL: Color = TEXT;
    const ROW_HOV: Color = Color {
        r: 0.22,
        g: 0.24,
        b: 0.28,
        a: 1.0,
    };

    let input_sty =
        |_: &Theme, _: iced::widget::text_input::Status| iced::widget::text_input::Style {
            background: Background::Color(INPUT_BG),
            border: Border {
                color: BORDER,
                width: 1.0,
                radius: 4.0.into(),
            },
            icon: TEXT,
            placeholder: DIM,
            value: TEXT,
            selection: Color {
                r: 0.20,
                g: 0.46,
                b: 0.80,
                a: 0.45,
            },
        };

    let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| {
        button(text(lbl).size(12).color(TEXT))
            .on_press(msg)
            .style(move |_: &Theme, st| button::Style {
                background: Some(Background::Color(
                    if matches!(st, button::Status::Hovered | button::Status::Pressed) {
                        hov
                    } else {
                        base
                    },
                )),
                text_color: TEXT,
                border: Border {
                    color: BORDER,
                    width: 1.0,
                    radius: 4.0.into(),
                },
                ..Default::default()
            })
            .padding([4, 12])
    };

    // ── Path bar ─────────────────────────────────────────────────────────
    let path_str = folder.to_string_lossy().into_owned();
    let up_path = folder.parent().map(|p| p.to_path_buf());
    let path_bar = row![
        {
            let up_msg = up_path.map(Message::SaveDialogNavigate);
            let b = button(text("↑").size(14).color(TEXT))
                .style(|_: &Theme, st| button::Style {
                    background: Some(Background::Color(
                        if matches!(st, button::Status::Hovered | button::Status::Pressed) {
                            BTN_GHOV
                        } else {
                            BTN_GREY
                        },
                    )),
                    text_color: TEXT,
                    border: Border {
                        color: BORDER,
                        width: 1.0,
                        radius: 4.0.into(),
                    },
                    ..Default::default()
                })
                .padding([3, 10]);
            if let Some(msg) = up_msg {
                b.on_press(msg)
            } else {
                b
            }
        },
        Space::new().width(8),
        container(text(path_str.clone()).size(12).color(DIM))
            .style(|_: &Theme| container::Style {
                background: Some(Background::Color(INPUT_BG)),
                border: Border {
                    color: BORDER,
                    width: 1.0,
                    radius: 4.0.into()
                },
                ..Default::default()
            })
            .padding([4, 8])
            .width(Fill),
    ]
    .align_y(iced::Alignment::Center);

    // ── File list ─────────────────────────────────────────────────────────
    let file_list: Element<'_, Message> = {
        let rows: Vec<Element<'_, Message>> = entries
            .iter()
            .map(|(name, is_dir, path)| {
                let icon = if *is_dir { "📁" } else { "📄" };
                let color = if *is_dir { DIR_COL } else { FILE_COL };
                let p = path.clone();
                let d = *is_dir;
                mouse_area(
                    container(
                        row![
                            text(icon).size(13),
                            Space::new().width(6),
                            text(crate::ui::text_util::elide(name.as_str(), 48))
                                .size(13)
                                .color(color),
                        ]
                        .align_y(iced::Alignment::Center),
                    )
                    .style(|_: &Theme| container::Style {
                        ..Default::default()
                    })
                    .padding([3, 8])
                    .width(Fill),
                )
                .on_press(Message::SaveDialogEntryClicked(p, d))
                .into()
            })
            .collect();

        container(iced::widget::scrollable(
            column(rows).spacing(1).width(Fill),
        ))
        .style(|_: &Theme| container::Style {
            background: Some(Background::Color(LIST_BG)),
            border: Border {
                color: BORDER,
                width: 1.0,
                radius: 4.0.into(),
            },
            ..Default::default()
        })
        .width(Fill)
        .height(Fill)
        .into()
    };
    let _ = ROW_HOV; // used conceptually, suppress warning

    let sel_fmt = SAVE_FORMAT_OPTIONS.iter().copied().find(|&s| s == format);
    let label = |s: &'static str| text(s).size(11).color(DIM);

    // ── Bottom controls ───────────────────────────────────────────────────
    let bottom = column![
        row![
            label("File name:").width(90),
            text_input("drawing.dwg", filename)
                .on_input(Message::SaveDialogFilenameChanged)
                .style(input_sty)
                .size(13)
                .padding([5, 8])
                .width(Fill),
        ]
        .align_y(iced::Alignment::Center)
        .spacing(6),
        Space::new().height(6),
        row![
            label("Format:").width(90),
            pick_list(SAVE_FORMAT_OPTIONS, sel_fmt, |s: &str| {
                Message::SaveDialogFormatChanged(s.to_string())
            })
            .width(Fill),
        ]
        .align_y(iced::Alignment::Center)
        .spacing(6),
        Space::new().height(12),
        row![
            Space::new().width(Fill),
            btn("Save", Message::SaveDialogConfirm, BTN_OK, BTN_HOV),
            Space::new().width(8),
            btn("Cancel", Message::SaveDialogCancel, BTN_GREY, BTN_GHOV),
        ],
    ]
    .spacing(0);

    container(
        column![
            path_bar,
            Space::new().height(8),
            file_list,
            Space::new().height(10),
            bottom,
        ]
        .spacing(0),
    )
    .style(|_: &Theme| container::Style {
        background: Some(Background::Color(BG)),
        ..Default::default()
    })
    .padding([14, 16])
    .width(Fill)
    .height(Fill)
    .into()
}

fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> {
    const BG: Color = Color {
        r: 0.18,
        g: 0.18,
        b: 0.20,
        a: 1.0,
    };
    const BORDER_COL: Color = Color {
        r: 0.38,
        g: 0.38,
        b: 0.42,
        a: 1.0,
    };
    const TEXT_COL: Color = Color {
        r: 0.90,
        g: 0.90,
        b: 0.90,
        a: 1.0,
    };
    const BTN_SAVE: Color = Color {
        r: 0.20,
        g: 0.46,
        b: 0.80,
        a: 1.0,
    };
    const BTN_HOVER: Color = Color {
        r: 0.26,
        g: 0.55,
        b: 0.92,
        a: 1.0,
    };
    const BTN_DISC: Color = Color {
        r: 0.28,
        g: 0.28,
        b: 0.30,
        a: 1.0,
    };
    const BTN_DHOV: Color = Color {
        r: 0.36,
        g: 0.36,
        b: 0.40,
        a: 1.0,
    };

    let body_text = format!("Do you want to save changes to \"{}\"?", name);

    let btn = |label: &'static str, msg: Message, base: Color, hov: Color| {
        button(text(label).size(13).color(TEXT_COL))
            .on_press(msg)
            .style(move |_: &Theme, status| button::Style {
                background: Some(Background::Color(match status {
                    button::Status::Hovered | button::Status::Pressed => hov,
                    _ => base,
                })),
                text_color: TEXT_COL,
                border: Border {
                    color: BORDER_COL,
                    width: 1.0,
                    radius: 4.0.into(),
                },
                shadow: iced::Shadow::default(),
                snap: false,
            })
            .padding([6, 18])
    };

    container(
        column![
            text(body_text).size(13).color(TEXT_COL),
            iced::widget::Space::new().height(20),
            row![
                btn("Save", Message::UnsavedDialogSave, BTN_SAVE, BTN_HOVER),
                iced::widget::Space::new().width(8),
                btn("Discard", Message::UnsavedDialogDiscard, BTN_DISC, BTN_DHOV),
                iced::widget::Space::new().width(8),
                btn("Cancel", Message::UnsavedDialogCancel, BTN_DISC, BTN_DHOV),
            ],
        ]
        .spacing(0),
    )
    .style(move |_: &Theme| container::Style {
        background: Some(Background::Color(BG)),
        ..Default::default()
    })
    .center(Fill)
    .padding([24, 28])
    .into()
}

/// First-launch prompt offering to register Open CAD Studio as the default
/// handler for .dwg / .dxf. "Yes" runs the platform association call; "Not now"
/// just dismisses. Either answer flips the persisted `default_assoc_prompted`
/// flag so the dialog never reappears.
fn default_assoc_dialog_window() -> Element<'static, Message> {
    const BG: Color = Color {
        r: 0.18,
        g: 0.18,
        b: 0.20,
        a: 1.0,
    };
    const BORDER_COL: Color = Color {
        r: 0.38,
        g: 0.38,
        b: 0.42,
        a: 1.0,
    };
    const TEXT_COL: Color = Color {
        r: 0.90,
        g: 0.90,
        b: 0.90,
        a: 1.0,
    };
    const DIM_COL: Color = Color {
        r: 0.62,
        g: 0.62,
        b: 0.66,
        a: 1.0,
    };
    const BTN_YES: Color = Color {
        r: 0.20,
        g: 0.46,
        b: 0.80,
        a: 1.0,
    };
    const BTN_YHOV: Color = Color {
        r: 0.26,
        g: 0.55,
        b: 0.92,
        a: 1.0,
    };
    const BTN_NO: Color = Color {
        r: 0.28,
        g: 0.28,
        b: 0.30,
        a: 1.0,
    };
    const BTN_NHOV: Color = Color {
        r: 0.36,
        g: 0.36,
        b: 0.40,
        a: 1.0,
    };

    let btn = |label: &'static str, msg: Message, base: Color, hov: Color| {
        button(text(label).size(13).color(TEXT_COL))
            .on_press(msg)
            .style(move |_: &Theme, status| button::Style {
                background: Some(Background::Color(match status {
                    button::Status::Hovered | button::Status::Pressed => hov,
                    _ => base,
                })),
                text_color: TEXT_COL,
                border: Border {
                    color: BORDER_COL,
                    width: 1.0,
                    radius: 4.0.into(),
                },
                shadow: iced::Shadow::default(),
                snap: false,
            })
            .padding([6, 18])
    };

    container(
        column![
            text("Make Open CAD Studio your default CAD app?")
                .size(15)
                .color(TEXT_COL),
            iced::widget::Space::new().height(10),
            text("Open .dwg and .dxf drawings in Open CAD Studio by default. You can change this later in your system settings.")
                .size(12)
                .color(DIM_COL),
            iced::widget::Space::new().height(22),
            row![
                iced::widget::Space::new().width(Fill),
                btn("Not now", Message::AssocPromptNo, BTN_NO, BTN_NHOV),
                iced::widget::Space::new().width(8),
                btn("Yes, set as default", Message::AssocPromptYes, BTN_YES, BTN_YHOV),
            ]
            .align_y(iced::Center),
        ]
        .spacing(0),
    )
    .style(move |_: &Theme| container::Style {
        background: Some(Background::Color(BG)),
        ..Default::default()
    })
    .center(Fill)
    .padding([24, 28])
    .into()
}

// ── Start / Welcome page ──────────────────────────────────────────────────
//
// Renders in place of the model-space viewport when the active tab is the
// fixed Start tab (`DocumentTab::is_start`). English-only by design — this
// is the public welcome screen and stays consistent across locales.
//
// The page picks up the application icon's red-brown (#B03020) as a tint so
// it visually belongs to OpenCADStudio without overpowering the dark workspace.

const BRAND: Color = Color {
    r: 0.690,
    g: 0.188,
    b: 0.125,
    a: 1.0,
}; // #B03020
const BRAND_DARK: Color = Color {
    r: 0.45,
    g: 0.12,
    b: 0.08,
    a: 1.0,
};

pub(super) fn start_page_view<'a>() -> Element<'a, Message> {
    const TEXT: Color = Color {
        r: 0.94,
        g: 0.93,
        b: 0.92,
        a: 1.0,
    };
    const MUTED: Color = Color {
        r: 0.62,
        g: 0.62,
        b: 0.62,
        a: 1.0,
    };
    const CARD_BG: Color = Color {
        r: 0.12,
        g: 0.12,
        b: 0.13,
        a: 1.0,
    };
    const CARD_BORDER: Color = Color {
        r: 0.20,
        g: 0.20,
        b: 0.22,
        a: 1.0,
    };

    // Brand-tinted "Welcome to" — the "OpenCADStudio" word takes the accent colour
    // (Thunderbird-style coloured headline split).
    let headline = row![
        text("Welcome to ").size(40).color(TEXT),
        text("Open CAD Studio").size(40).color(BRAND),
    ]
    .align_y(iced::Center);

    let subtitle = text(
        "Open CAD Studio is an open-source CAD viewer and editor — a gift from contributors like you. \
         Open a DWG/DXF file, start a new drawing, or help shape what comes next.",
    )
    .size(13)
    .color(MUTED);

    // Plain outlined button (Open / New / Help / Contribute).
    let outline_btn = |label: &'static str, msg: Message| {
        button(text(label).size(14).color(TEXT))
            .on_press(msg)
            .padding([10, 22])
            .style(move |_: &Theme, status| button::Style {
                background: Some(Background::Color(match status {
                    button::Status::Hovered => Color {
                        r: 0.18,
                        g: 0.18,
                        b: 0.20,
                        a: 1.0,
                    },
                    _ => Color {
                        r: 0.13,
                        g: 0.13,
                        b: 0.15,
                        a: 1.0,
                    },
                })),
                text_color: TEXT,
                border: Border {
                    color: Color {
                        r: 0.30,
                        g: 0.30,
                        b: 0.33,
                        a: 1.0,
                    },
                    width: 1.0,
                    radius: 6.0.into(),
                },
                ..Default::default()
            })
    };

    // Donate — the prominent call-to-action. Solid brand fill, white text.
    let donate_btn = {
        button(
            row![
                text("♥ ").size(15).color(Color::WHITE),
                text("Donate").size(14).color(Color::WHITE),
            ]
            .align_y(iced::Center),
        )
        .on_press(Message::RibbonToolClick {
            tool_id: "DONATE".to_string(),
            event: crate::modules::ModuleEvent::Command("DONATE".to_string()),
        })
        .padding([12, 28])
        .style(|_: &Theme, status| button::Style {
            background: Some(Background::Color(match status {
                button::Status::Hovered => BRAND_DARK,
                _ => BRAND,
            })),
            text_color: Color::WHITE,
            border: Border {
                color: BRAND_DARK,
                width: 1.0,
                radius: 6.0.into(),
            },
            shadow: iced::Shadow {
                color: Color {
                    r: 0.0,
                    g: 0.0,
                    b: 0.0,
                    a: 0.4,
                },
                offset: iced::Vector::new(0.0, 2.0),
                blur_radius: 6.0,
            },
            ..Default::default()
        })
    };

    let primary_row = row![
        outline_btn("New Drawing", Message::TabNew),
        outline_btn("Open File…", Message::OpenFile),
        donate_btn,
    ]
    .spacing(12)
    .align_y(iced::Center);

    let secondary_row = row![
        outline_btn(
            "Send Feedback",
            Message::RibbonToolClick {
                tool_id: "REPORT".to_string(),
                event: crate::modules::ModuleEvent::Command("REPORT".to_string()),
            },
        ),
        outline_btn(
            "Release Notes",
            Message::RibbonToolClick {
                tool_id: "CHANGELOG".to_string(),
                event: crate::modules::ModuleEvent::Command("CHANGELOG".to_string()),
            },
        ),
        outline_btn("Plugins", Message::PluginManagerOpen),
        outline_btn("About", Message::AboutOpen),
    ]
    .spacing(12)
    .align_y(iced::Center);

    let card_section = |heading: &'static str, sub: &'static str, body: &'static str| {
        container(
            column![
                text(heading).size(20).color(TEXT),
                text(sub).size(13).color(MUTED),
                Space::new().height(iced::Length::Fixed(10.0)),
                text(body).size(12).color(MUTED),
            ]
            .spacing(6),
        )
        .padding(20)
        .width(Fill)
        .style(|_: &Theme| container::Style {
            background: Some(Background::Color(CARD_BG)),
            border: Border {
                color: CARD_BORDER,
                width: 1.0,
                radius: 8.0.into(),
            },
            ..Default::default()
        })
    };

    let cards = row![
        card_section(
            "Free and open source",
            "Open CAD Studio belongs to its users.",
            "Source is openly developed and distributed under a permissive licence. \
             You are free to use, study, modify, and share Open CAD Studio.",
        ),
        card_section(
            "Community driven",
            "Be part of the story.",
            "Anyone can contribute — translations, bug reports, feature ideas, code, \
             or simply spreading the word. Your involvement shapes the project.",
        ),
    ]
    .spacing(16);

    // Buttons sit on a transparent container with a large, brand-tinted
    // ambient shadow (offset = 0, big blur) — produces a soft halo behind
    // the action row, matching the Thunderbird coloured-glow look against
    // the dark page.
    let primary_glow = container(primary_row)
        .padding(iced::Padding {
            top: 4.0,
            right: 8.0,
            bottom: 4.0,
            left: 8.0,
        })
        .style(|_: &Theme| container::Style {
            background: Some(Background::Color(Color::TRANSPARENT)),
            shadow: iced::Shadow {
                color: Color {
                    r: BRAND.r,
                    g: BRAND.g,
                    b: BRAND.b,
                    a: 0.45,
                },
                offset: iced::Vector::ZERO,
                blur_radius: 80.0,
            },
            ..Default::default()
        });

    let content = column![
        Space::new().height(iced::Length::Fixed(28.0)),
        container(headline).center_x(Fill),
        container(subtitle).center_x(Fill).padding([10, 60]),
        Space::new().height(iced::Length::Fixed(22.0)),
        container(primary_glow).center_x(Fill),
        Space::new().height(iced::Length::Fixed(10.0)),
        container(secondary_row).center_x(Fill),
        Space::new().height(iced::Length::Fixed(40.0)),
        cards,
        Space::new().height(Fill),
    ]
    .spacing(0)
    .width(Fill)
    .height(Fill);

    // Page background reverts to plain dark — the glow alone provides the
    // brand colour cue, the rest of the page stays neutral so it reads as
    // "workspace area" not "advertising banner".
    const PAGE_BG: Color = Color {
        r: 0.08,
        g: 0.08,
        b: 0.085,
        a: 1.0,
    };
    container(content)
        .style(|_: &Theme| container::Style {
            background: Some(Background::Color(PAGE_BG)),
            ..Default::default()
        })
        .padding(iced::Padding {
            top: 40.0,
            right: 60.0,
            bottom: 40.0,
            left: 60.0,
        })
        .width(Fill)
        .height(Fill)
        .into()
}

// ── Recent Documents panel (Start tab left rail) ──────────────────────────
//
// Slots into the same `row![properties_el, viewport_stack]` position the
// Properties panel normally occupies, but only when the active tab is the
// Start tab. The list is restored from disk at boot and re-saved on every
// open — entries persist across sessions.
pub(super) fn recent_files_panel<'a>(recents: &'a [std::path::PathBuf]) -> Element<'a, Message> {
    const PANEL_BG: Color = Color {
        r: 0.10,
        g: 0.10,
        b: 0.11,
        a: 1.0,
    };
    const PANEL_BORDER: Color = Color {
        r: 0.18,
        g: 0.18,
        b: 0.20,
        a: 1.0,
    };
    const ITEM_HOVER: Color = Color {
        r: 0.16,
        g: 0.16,
        b: 0.18,
        a: 1.0,
    };
    const TEXT: Color = Color {
        r: 0.92,
        g: 0.91,
        b: 0.90,
        a: 1.0,
    };
    const MUTED: Color = Color {
        r: 0.60,
        g: 0.60,
        b: 0.62,
        a: 1.0,
    };

    let header = container(text("Recent Documents").size(11).color(MUTED)).padding(iced::Padding {
        top: 12.0,
        right: 14.0,
        bottom: 8.0,
        left: 14.0,
    });

    let body: Element<'a, Message> = if recents.is_empty() {
        container(
            text("Files you open will show up here.")
                .size(11)
                .color(MUTED),
        )
        .padding([10, 14])
        .into()
    } else {
        let mut col = column![].spacing(0);
        for path in recents {
            let name = path
                .file_name()
                .map(|n| n.to_string_lossy().into_owned())
                .unwrap_or_else(|| path.to_string_lossy().into_owned());
            let dir = path
                .parent()
                .map(|p| p.to_string_lossy().into_owned())
                .unwrap_or_default();

            let path_for_open = path.clone();
            let open_btn = button(
                column![
                    text(crate::ui::text_util::elide(&name, 32))
                        .size(12)
                        .color(TEXT),
                    text(crate::ui::text_util::elide(&dir, 42))
                        .size(10)
                        .color(MUTED),
                ]
                .spacing(2),
            )
            .on_press(Message::OpenRecent(path_for_open))
            .padding([6, 12])
            .width(Fill)
            .style(move |_: &Theme, status| button::Style {
                background: Some(Background::Color(match status {
                    button::Status::Hovered => ITEM_HOVER,
                    _ => Color::TRANSPARENT,
                })),
                text_color: TEXT,
                border: Border {
                    color: Color::TRANSPARENT,
                    width: 0.0,
                    radius: 0.0.into(),
                },
                ..Default::default()
            });

            let path_for_remove = path.clone();
            let remove_btn = button(text("×").size(12).color(MUTED))
                .on_press(Message::RecentRemove(path_for_remove))
                .padding([4, 8])
                .style(|_: &Theme, status| button::Style {
                    background: Some(Background::Color(match status {
                        button::Status::Hovered => Color {
                            r: 0.45,
                            g: 0.15,
                            b: 0.15,
                            a: 1.0,
                        },
                        _ => Color::TRANSPARENT,
                    })),
                    text_color: MUTED,
                    border: Border {
                        color: Color::TRANSPARENT,
                        width: 0.0,
                        radius: 3.0.into(),
                    },
                    ..Default::default()
                });

            col = col.push(row![open_btn, remove_btn].spacing(0).align_y(iced::Center));
        }
        iced::widget::scrollable(col).into()
    };

    container(column![header, body])
        .width(iced::Length::Fixed(280.0))
        .height(Fill)
        .style(|_: &Theme| container::Style {
            background: Some(Background::Color(PANEL_BG)),
            border: Border {
                color: PANEL_BORDER,
                width: 1.0,
                radius: 0.0.into(),
            },
            ..Default::default()
        })
        .into()
}

// ── Render-mode picker ──────────────────────────────────────────────────────

/// The 7-way visual-style `pick_list`, styled as a dark overlay chip.
/// Shared by the model-space viewport (top-left) and each active
/// paper-space viewport. Emits `SetRenderMode`, which the update loop
/// routes to the active viewport entity or the model-layout tab.
fn render_mode_picker<'a>(current: acadrust::entities::ViewportRenderMode) -> Element<'a, Message> {
    use acadrust::entities::ViewportRenderMode as M;
    let render_modes: Vec<RenderModeChoice> = vec![
        RenderModeChoice(M::Wireframe2D),
        RenderModeChoice(M::Wireframe3D),
        RenderModeChoice(M::HiddenLine),
        RenderModeChoice(M::FlatShaded),
        RenderModeChoice(M::GouraudShaded),
        RenderModeChoice(M::FlatShadedWithEdges),
        RenderModeChoice(M::GouraudShadedWithEdges),
    ];
    container(
        iced::widget::pick_list(render_modes, Some(RenderModeChoice(current)), |c| {
            Message::SetRenderMode(c.0)
        })
        .text_size(11),
    )
    .style(|_: &Theme| iced::widget::container::Style {
        background: Some(iced::Background::Color(Color {
            r: 0.10,
            g: 0.10,
            b: 0.10,
            a: 0.75,
        })),
        border: iced::Border {
            color: Color {
                r: 0.35,
                g: 0.35,
                b: 0.35,
                a: 1.0,
            },
            width: 1.0,
            radius: 4.0.into(),
        },
        text_color: Some(Color {
            r: 0.85,
            g: 0.85,
            b: 0.85,
            a: 1.0,
        }),
        ..Default::default()
    })
    .padding([4, 8])
    .into()
}

// ── Dynamic-input field formatting ─────────────────────────────────────────

/// Short prefix shown before a dynamic-input box's value.
/// The string shown inside a dynamic-input box: the typed buffer when the
/// field is locked, otherwise the live value derived from the cursor
/// world position (and the base point for polar quantities).
fn dyn_component_value(f: &DynFieldEntry, w: glam::Vec3, base: Option<glam::Vec3>) -> String {
    if let Some(b) = &f.buffer {
        return b.clone();
    }
    let b = base.unwrap_or(glam::Vec3::ZERO);
    let dx = (w.x - b.x) as f64;
    let dy = (w.y - b.y) as f64;
    // When a base point exists (DYN-on after the first pick) the cartesian
    // fields show relative deltas — matching the typed-value convention
    // in `dyn_resolve_point` so the live preview and the committed
    // coordinate use the same frame. See #35.
    let has_base = base.is_some();
    // Width / Height read as unsigned magnitudes (the sign is taken from the
    // cursor side on commit), matching the rectangle's two-edge entry.
    let wh = matches!(f.role, crate::command::DynRole::Width | crate::command::DynRole::Height);
    match f.component {
        DynComponent::X if has_base => format!("{:.4}", if wh { dx.abs() } else { dx }),
        DynComponent::Y if has_base => format!("{:.4}", if wh { dy.abs() } else { dy }),
        DynComponent::Z if has_base => "0.0000".to_string(),
        DynComponent::X => format!("{:.4}", w.x),
        DynComponent::Y => format!("{:.4}", w.y),
        DynComponent::Z => format!("{:.4}", b.z),
        // Scaled by the role so a diameter box reads twice the radius.
        DynComponent::Distance => {
            format!("{:.4}", (dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64)
        }
        // Shared rule: unsigned magnitude of the short angle, so CW (below the
        // reference axis) reads positive (e.g. 30°, not -30°/330°). The
        // committed value stays signed (see dyn_resolve_point).
        DynComponent::Angle => {
            format!("{:.1}", crate::command::dyn_display_angle_deg(dy.atan2(dx) as f32))
        }
        // Typed-only scalar — no geometric value to track when empty.
        DynComponent::Scalar => String::new(),
    }
}
