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 ───────────────────────────────────────── // All dialogs are in-canvas modals now (Plan B); view_main stacks the // active one. `window_id` is unused — there is only the main window. let _ = window_id; 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 = 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); // Model layout: one grid per tile that has grid display on, drawn // in its own bounds + camera so panes are independent and never // flicker on hover. Paper layout: a single grid gated by show_grid. let grid: Vec = if !is_paper { tab.scene .model_tile_grid_views(vw, vh) .into_iter() .map(|(bounds, cam)| { let plane = grid_plane_from_camera(cam.pitch, cam.yaw); overlay::GridParams { view_proj: cam.view_proj(bounds), bounds, plane, } }) .collect() } else if self.show_grid { let cam = tab.scene.camera.borrow(); let plane = grid_plane_from_camera(cam.pitch, cam.yaw); vec![overlay::GridParams { view_proj: cam.view_proj(vp_bounds), bounds: vp_bounds, plane, }] } else { Vec::new() }; 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 = 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, ) }; 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> = 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 = 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 { // Unified control chip: split buttons + render-mode picker + // grid / grid-snap toggles, for the active Model tile. let bar = viewport_controls( tab.render_mode, self.show_grid, self.snapper.is_on(crate::snap::SnapType::Grid), true, ); // 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 = 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(viewport_controls( vp_mode, self.show_grid, self.snapper.is_on(crate::snap::SnapType::Grid), false, )), ], ] .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 mark: Element<'_, Message> = if is_cur { crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, Color::WHITE) } else { Space::new().width(11).into() }; let btn = button( row![ container(mark).width(16), text(name).size(12).color(Color::WHITE), ] .spacing(2) .align_y(iced::Center), ) .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); } } // In-place block edit (REFEDIT): right-edge toolbar with Save / Discard // so the edit can be finished by clicking. (#136) if tab.refedit_session.is_some() && !tab.is_start { if let Some(tb) = crate::ui::side_toolbar::view( &crate::modules::draw::modify::refedit::refedit_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 = 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 = 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.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 = 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() }; let composed = 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, ]; // ── In-canvas modal dialogs (Plan B) ─────────────────────────────── // Former pop-up windows render as overlays here, so they work on both // the native (single main window) and web builds. let base: Element<'_, Message> = match self.modal_content() { Some(content) => { crate::ui::modal::modal(composed, content, Message::CloseModal, self.modal_offset) } None => composed.into(), }; // The colour picker is a nested modal: it stacks over whichever dialog // (style editor, properties, …) requested it. if self.color_pick_target.is_some() { crate::ui::modal::modal( base, iced::widget::container(crate::ui::color_select::color_grid_window( Message::ColorWindowPick, )) .width(iced::Length::Fixed(420.0)) .height(iced::Length::Fixed(470.0)), Message::CloseColorPicker, iced::Vector::ZERO, ) } else { base } } /// Outer pixel size (content + title-bar/padding chrome) of the active /// modal, used to clamp drag so it cannot be pushed off-screen. Mirrors the /// `sized(..)` dimensions in [`Self::modal_content`]; keep the two in sync. /// `None` has no active modal. About (content-sized) uses a safe estimate. pub(crate) fn modal_outer_size(&self) -> Option<(f32, f32)> { use super::ModalKind::*; // Title bar (~26) + spacing (6) + frame padding (10·2) → ~52 vertical; // frame padding → ~20 horizontal. const EXTRA_W: f32 = 20.0; const EXTRA_H: f32 = 52.0; let (w, h) = match self.active_modal? { About => (440, 360), Shortcuts => (720, 520), PluginManager => (520, 460), UpdateNotice => (560, 460), Layers => (900, 360), PageSetup => (520, 460), LayoutManager => (640, 320), Plotstyle => (780, 540), TextStyle => (860, 480), MlStyle => (620, 420), TableStyle => (620, 420), MLeaderStyle => (560, 560), DimStyle => (720, 560), AssocPrompt => (440, 210), Unsaved => (420, 160), SaveDialog => (560, 480), PointStyle => (360, 470), }; Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H)) } /// Build the currently-open modal dialog's content (Plan B), or `None`. /// Each former pop-up window is constructed here and given a bounded size /// (About shrinks to its content). Rendered as an overlay by `view_main`. fn modal_content(&self) -> Option> { fn sized<'a>(e: Element<'a, Message>, w: u16, h: u16) -> Element<'a, Message> { iced::widget::container(e) .width(iced::Length::Fixed(w as f32)) .height(iced::Length::Fixed(h as f32)) .into() } Some(match self.active_modal? { super::ModalKind::About => crate::ui::about::view_window(), super::ModalKind::Shortcuts => { sized(crate::ui::shortcuts::view_window(&self.shortcut_overrides), 720, 520) } super::ModalKind::PluginManager => sized( crate::ui::plugin_manager::view_window( &self.disabled_plugins, &self.external_plugins, &self.loaded_plugin_ids, crate::ui::plugin_manager::MarketView { registry: &self.plugin_registry, input: &self.plugin_repo_input, repos: &self.plugin_repos, release_tags: &self.repo_release_tags, selected_tag: &self.repo_selected_tag, status: &self.marketplace_status, }, ), 520, 460, ), super::ModalKind::UpdateNotice => { let latest = self.update_notice_version.as_deref().unwrap_or("?"); let body = self.update_notice_body.as_deref().unwrap_or(""); sized(crate::ui::update_notice::view_window(latest, body), 560, 460) } super::ModalKind::Layers => { let tab = &self.tabs[self.active_tab]; sized(tab.layers.view_window(), 900, 360) } super::ModalKind::PageSetup => sized( 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, ), 520, 460, ), super::ModalKind::LayoutManager => { let i = self.active_tab; let layouts = self.tabs[i].scene.layout_names(); let current = self.tabs[i].scene.current_layout.clone(); sized( crate::ui::layout_manager::view_window( layouts, &self.layout_manager_selected, &self.layout_manager_rename_buf, current, ), 640, 320, ) } super::ModalKind::Plotstyle => sized( 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, ), 780, 540, ), super::ModalKind::TextStyle => { let tab = &self.tabs[self.active_tab]; let styles: Vec = 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)); sized( 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, }), // Wider than the old 620 window: the TTF system-font panel // (Plan B / web fonts) added a column. 860, 480, ) } super::ModalKind::MlStyle => { use acadrust::objects::ObjectType; let tab = &self.tabs[self.active_tab]; let styles: Vec = tab .scene .document .objects .values() .filter_map(|o| match o { ObjectType::MLineStyle(s) => Some(s.name.clone()), _ => None, }) .collect(); let selected_style = tab.scene.document.objects.values().find_map(|o| match o { ObjectType::MLineStyle(s) if s.name == self.mlstyle_selected => Some(s), _ => None, }); sized( 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, ), 620, 420, ) } super::ModalKind::TableStyle => { use acadrust::objects::ObjectType; let tab = &self.tabs[self.active_tab]; let styles: Vec = tab .scene .document .objects .values() .filter_map(|o| match o { ObjectType::TableStyle(s) => Some(s.name.clone()), _ => None, }) .collect(); let selected_style = tab.scene.document.objects.values().find_map(|o| match o { ObjectType::TableStyle(s) if s.name == self.tablestyle_selected => Some(s), _ => None, }); sized( 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, ), 620, 420, ) } super::ModalKind::MLeaderStyle => { use acadrust::objects::ObjectType; let tab = &self.tabs[self.active_tab]; let styles: Vec = tab .scene .document .objects .values() .filter_map(|o| match o { ObjectType::MultiLeaderStyle(s) => Some(s.name.clone()), _ => None, }) .collect(); let selected_style = tab.scene.document.objects.values().find_map(|o| match o { ObjectType::MultiLeaderStyle(s) if s.name == self.mleaderstyle_selected => { Some(s) } _ => None, }); let doc = &tab.scene.document; let mut block_opts: Vec = vec!["None".to_string()]; block_opts.extend(doc.block_records.iter().map(|b| b.name.clone())); let mut lt_opts: Vec = vec!["None".to_string()]; lt_opts.extend(doc.line_types.iter().map(|lt| lt.name.clone())); let mut textstyle_opts: Vec = vec!["None".to_string()]; textstyle_opts.extend(doc.text_styles.iter().map(|t| t.name.clone())); let opt_block = |h: Option| -> 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| -> 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| -> 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(), }; sized( 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, }), 560, 560, ) } super::ModalKind::DimStyle => { let tab = &self.tabs[self.active_tab]; let styles: Vec = 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 = vec!["Default".to_string()]; block_opts.extend(doc.block_records.iter().map(|b| b.name.clone())); let mut lt_opts: Vec = 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(), }; sized(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, ), 720, 560) } super::ModalKind::AssocPrompt => sized(default_assoc_dialog_window(), 440, 210), super::ModalKind::Unsaved => { 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(), }; sized(unsaved_changes_dialog_window(&tab_name), 420, 160) } super::ModalKind::PointStyle => sized( crate::ui::point_style::view_window( self.tabs[self.active_tab].scene.document.header.point_display_mode, self.point_size_relative, &self.point_size_buf, ), 360, 470, ), super::ModalKind::SaveDialog => sized( save_as_dialog_window( &self.save_dialog_filename, &self.save_dialog_folder, &self.save_dialog_entries, &self.save_dialog_format, ), 560, 480, ), }) } pub fn subscription(&self) -> Subscription { 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() }; // While a rollover pick is queued, drive ticks so it fires the // moment the cursor has been still for the dwell window — without // this `ViewportMove` alone never re-fires once the user stops. let hover_dwell = if self.hover_dwell.is_some() { window::frames().map(|_| Message::HoverDwellTick) } 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() }; // Web: poll for per-script fonts that a drawing's text needs but hasn't // fetched yet. Cheap — `PollWebFonts` is a no-op when nothing is // pending. Native has system fonts, so no polling. (#141) #[cfg(target_arch = "wasm32")] let web_fonts = iced::time::every(std::time::Duration::from_millis(300)).map(|_| Message::PollWebFonts); #[cfg(not(target_arch = "wasm32"))] let web_fonts = Subscription::none(); iced::Subscription::batch([ frames, history_tick, grip_dwell, hover_dwell, caret_blink, web_fonts, 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::PasteShortcut), _ => 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 { 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 title_inner: Element<'_, Message> = if tab.dirty { row![ crate::ui::icons::tinted( crate::ui::icons::DOT, 7.0, Color { r: 0.90, g: 0.75, b: 0.30, a: 1.0, }, ), text(name).size(12), ] .spacing(5) .align_y(iced::Center) .into() } else { text(name).size(12).into() }; let title_btn = button(title_inner) .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(crate::ui::icons::tinted( crate::ui::icons::CLOSE, 10.0, 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, /// 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 for MTextPreview { type State = MTextPreviewState; fn update( &self, state: &mut MTextPreviewState, event: &iced::Event, bounds: iced::Rectangle, cursor: iced::mouse::Cursor, ) -> Option> { 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 { 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, 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 = 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::>(), 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::>(), 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, 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> = 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 do_caret = if draworder_open { crate::ui::icons::arrow_down(9.0, TEXT_COL) } else { crate::ui::icons::arrow_right(9.0, TEXT_COL) }; items.push( button( row![ text("Draw Order").size(12).color(TEXT_COL), iced::widget::Space::new().width(Fill), do_caret, ] .align_y(iced::Center), ) .on_press(Message::DrawOrderSubmenuToggle) .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(), ); 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)); items.push(item( "Invert Selection".to_string(), Message::InvertSelection, )); } 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 = vec![QSELECT_ANY_TYPE.to_string()]; type_options.extend(types.iter().map(|s| (*s).to_string())); let mut prop_options: Vec = 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 = 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(crate::ui::icons::tinted(crate::ui::icons::UP, 14.0, 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> = entries .iter() .map(|(name, is_dir, path)| { let icon_bytes = if *is_dir { crate::ui::icons::FOLDER } else { crate::ui::icons::DOC }; let color = if *is_dir { DIR_COL } else { FILE_COL }; let p = path.clone(); let d = *is_dir; mouse_area( container( row![ crate::ui::icons::tinted(icon_bytes, 13.0, color), 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![ crate::ui::icons::tinted(crate::ui::icons::HEART, 14.0, Color::WHITE), text("Donate").size(14).color(Color::WHITE), ] .spacing(5) .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 mut 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), ]; // The web build is already in the browser, so only the desktop offers a // link to the web version. #[cfg(not(target_arch = "wasm32"))] { // Bright ribbon blue (matches the active-tool accent), filled. secondary_row = secondary_row.push( button(text("OCS Web").size(14).color(Color::WHITE)) .on_press(Message::RibbonToolClick { tool_id: "WEBVERSION".to_string(), event: crate::modules::ModuleEvent::Command("WEBVERSION".to_string()), }) .padding([10, 22]) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered => Color { r: 0.15, g: 0.45, b: 0.78, a: 1.0, }, _ => Color { r: 0.20, g: 0.55, b: 0.90, a: 1.0, }, })), text_color: Color::WHITE, border: Border { color: Color { r: 0.20, g: 0.55, b: 0.90, a: 1.0, }, width: 1.0, radius: 6.0.into(), }, ..Default::default() }), ); } let secondary_row = secondary_row.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(crate::ui::icons::tinted(crate::ui::icons::CLOSE, 11.0, 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 ────────────────────────────────────────────────────── /// Top-left viewport control bar: a single dark chip holding (optionally) the /// horizontal/vertical split buttons, the render-mode picker, and the grid / /// grid-snap toggles. `include_split` is off for paper-space viewports, which /// have no model-tile splitting. Grid / snap reflect the active viewport's /// state and emit `ToggleGrid` / `ToggleGridSnap`. fn viewport_controls<'a>( render_mode: acadrust::entities::ViewportRenderMode, show_grid: bool, snap_on: bool, include_split: bool, ) -> Element<'a, Message> { use acadrust::entities::ViewportRenderMode as M; let render_modes: Vec = vec![ RenderModeChoice(M::Wireframe2D), RenderModeChoice(M::Wireframe3D), RenderModeChoice(M::HiddenLine), RenderModeChoice(M::FlatShaded), RenderModeChoice(M::GouraudShaded), RenderModeChoice(M::FlatShadedWithEdges), RenderModeChoice(M::GouraudShadedWithEdges), ]; let light = Color { r: 0.85, g: 0.85, b: 0.85, a: 1.0 }; let accent = Color { r: 0.45, g: 0.70, b: 1.0, a: 1.0 }; // Borderless icon button; an `active` toggle gets an accent tint + fill. let icon_btn = move |bytes: &'static [u8], active: bool, msg: Message| { let tint = if active { accent } else { light }; button(crate::ui::icons::tinted(bytes, 15.0, tint)) .on_press(msg) .padding([4, 6]) .style(move |_: &Theme, status| iced::widget::button::Style { background: Some(Background::Color(match (active, status) { (_, iced::widget::button::Status::Hovered) => Color { r: 0.25, g: 0.25, b: 0.25, a: 0.9, }, (true, _) => Color { r: 0.16, g: 0.22, b: 0.32, a: 0.9, }, (false, _) => Color::TRANSPARENT, })), border: Border { radius: 3.0.into(), ..Default::default() }, text_color: tint, ..Default::default() }) }; // Render-mode picker, restyled borderless so the outer chip frames it. let picker = iced::widget::pick_list( render_modes, Some(RenderModeChoice(render_mode)), |c| Message::SetRenderMode(c.0), ) .text_size(11) .padding([4, 6]) .style(move |_: &Theme, _| iced::widget::pick_list::Style { background: Background::Color(Color::TRANSPARENT), border: Border { radius: 3.0.into(), ..Default::default() }, text_color: light, placeholder_color: light, handle_color: light, }); // Thin vertical divider between control groups. let sep = || { container(iced::widget::Space::new().width(1.0).height(16.0)).style(|_: &Theme| { iced::widget::container::Style { background: Some(Background::Color(Color { r: 0.45, g: 0.45, b: 0.45, a: 0.7, })), ..Default::default() } }) }; let mut bar = row![] .spacing(3) .align_y(iced::alignment::Vertical::Center); if include_split { bar = bar .push(icon_btn(crate::ui::icons::SPLIT_H, false, Message::SplitModelViewport(true))) .push(sep()) .push(icon_btn(crate::ui::icons::SPLIT_V, false, Message::SplitModelViewport(false))) .push(sep()); } bar = bar .push(picker) .push(sep()) .push(icon_btn(crate::ui::icons::GRID, show_grid, Message::ToggleGrid)) .push(sep()) .push(icon_btn(crate::ui::icons::SNAP, snap_on, Message::ToggleGridSnap)); container(bar) .padding(2) .style(|_: &Theme| iced::widget::container::Style { background: Some(Background::Color(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(), }, ..Default::default() }) .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) -> 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(), } }