Files
OneCAD-Draft/src/app/view.rs
T
Kevin GriffinandClaude Opus 4.8 ef229f5d85 fix(save-dialog): allow navigating to other drives (#170)
The custom Save As dialog navigated up the tree solely via
`Path::parent()`, which returns `None` at a drive root (`C:\`).
Navigation therefore dead-ended on the starting volume, so other
drives and USB sticks were unreachable.

Add a synthetic Windows "This PC" drives root above the drive
letters. `parent_folder()` now climbs `…\sub → C:\ → This PC`, and
`read_dir_entries()` lists the available volumes (probed A:–Z: by
stat, no new dependency) when at that root. The dialog's up-button
and path bar route through the same helper and show "This PC".

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 23:45:30 +03:00

4458 lines
165 KiB
Rust

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, grips_to_screen_rte};
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 {
#[cfg(not(target_arch = "wasm32"))]
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<overlay::GripMarker> =
if tab.active_cmd.is_none() && !tab.selected_grips.is_empty() {
let (vw, vh) = tab.scene.selection.borrow().vp_size;
// Overlays project through the active tile's camera, so
// they must use the active tile's screen rectangle (with
// its canvas offset) — not the whole canvas — or they
// land in the wrong place in a tiled layout.
// Inside a floating viewport the pane is the viewport's own
// rect + camera; otherwise the active model tile.
let edit_frame = tab.scene.viewport_edit_frame((vw, vh));
let bounds = match &edit_frame {
Some((_, full)) => *full,
None => tab.scene.active_model_tile_bounds(vw, vh),
};
let sel_h = tab.selected_handle;
// In-viewport grips are model-space; project them with the
// viewport camera so they sit on the wire the GPU draws.
// Paper entities use the 2-D paper transform; the model tab
// uses the model camera.
let screen_grips = if let Some((cam, _)) = &edit_frame {
grips_to_screen_rte(
&tab.selected_grips,
cam.view_proj_rte(bounds),
cam.eye(),
bounds,
)
} else 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 as f32;
let ty = cam.target.y as f32;
drop(cam);
grips_to_screen_paper(&tab.selected_grips, tx, ty, half_w, half_h, bounds)
} else {
let cam = tab.scene.camera.borrow();
grips_to_screen(&tab.selected_grips, &cam, 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);
// Each view draws its own grid, clipped to its own bounds, so they
// stay independent: model tiles in model space; the sheet plus each
// floating viewport (clipped to its rectangle) in paper space. One
// enumeration for both, shared with the renderer.
// Align each grid to its pane's UCS (origin in wire space, world
// axis directions): model tiles to the tab's model UCS, each content
// viewport to its own per-viewport UCS, the paper sheet to plain WCS.
let model_basis = {
let (o, ux, uy, uz) = tab.ucs_xform().axes();
(o.as_dvec3(), (ux, uy, uz))
};
let grid: Vec<overlay::GridParams> = tab
.scene
.grid_views(vw, vh)
.into_iter()
.map(|(bounds, cam, handle)| {
let (origin, axes): (glam::DVec3, _) = if is_paper {
match tab.ucs_from_viewport(handle) {
Some(u) => {
let (o, ux, uy, uz) =
super::helpers::UcsXform::from_ucs(&u).axes();
(o.as_dvec3(), (ux, uy, uz))
}
None => (
glam::DVec3::ZERO,
(glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z),
),
}
} else {
model_basis
};
let plane = grid_plane_from_camera(cam.pitch, cam.yaw);
overlay::GridParams {
view_rot: cam.view_proj_rte(bounds),
eye: cam.eye(),
bounds,
plane,
origin,
axes,
}
})
.collect();
// The UCS icon shows the active pane's UCS tripod: the model view, or
// (inside a floating viewport) projected through the viewport camera
// at the viewport's rect so it tracks the in-viewport UCS.
// Rotation-only projection (view_proj_rte): the icon shows axis
// DIRECTIONS only, so the full view_proj's huge UTM translation would
// cancel catastrophically in f32 and make the tripod jitter.
let ucs_icon = if !self.show_ucs_icon {
None
} else if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) {
let (_, ux, uy, uz) = tab.ucs_xform().axes();
Some(overlay::UcsIconParams {
view_proj: vp_cam.view_proj_rte(full),
bounds: full,
axes: (ux, uy, uz),
})
} else if !is_paper {
let cam = tab.scene.camera.borrow();
let (_, ux, uy, uz) = tab.ucs_xform().axes();
Some(overlay::UcsIconParams {
view_proj: cam.view_proj_rte(vp_bounds),
bounds: vp_bounds,
axes: (ux, uy, uz),
})
} else {
None
};
// OST tracking points → screen positions, projected relative-to-eye
// so they stay precise at UTM-scale coordinates (the full
// view-projection cancels catastrophically in f32).
let ost_points: Vec<overlay::OstTrackPoint> = if self.snapper.otrack_enabled {
let (view_rot, eye) = {
let cam = tab.scene.camera.borrow();
(cam.view_proj_rte(vp_bounds), cam.eye())
};
self.snapper
.tracking_points
.iter()
.map(|&wp| {
let ndc = view_rot.project_point3((wp.as_dvec3() - eye).as_vec3());
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<Element<'_, Message>> =
if self.dyn_input
&& tab.active_cmd.is_some()
&& (!tab.dyn_fields.is_empty() || dyn_picks_object)
{
let w = tab.last_cursor_world;
let base = self.last_point;
// A command may drive a typed scalar by mouse (e.g. a
// perpendicular distance to a picked object); show that live
// value in the box until the user types over it.
let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w.as_dvec3()));
let boxes: Vec<overlay::DynBox> = tab
.dyn_fields
.iter()
.enumerate()
.map(|(idx, f)| {
let value = match (&f.buffer, live) {
(Some(b), _) => b.clone(),
// An angle step with a command-supplied live value
// (ARC span / direction) shows it in degrees.
(None, Some(lv)) if f.component == DynComponent::Angle => {
format!("{lv:.1}")
}
(None, Some(lv))
if matches!(
f.component,
DynComponent::Scalar | DynComponent::Distance
) =>
{
format!("{lv:.4}")
}
_ => dyn_component_value(f, w, base, &tab.ucs_xform()),
};
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.grid_snap(),
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<iced::Rectangle> = if is_paper && !tab.is_start {
tab.scene.active_viewport.and_then(|h| {
let (cw, ch) = tab.scene.selection.borrow().vp_size;
tab.scene.viewport_screen_rect(h, (cw, ch))
})
} else {
None
};
if let Some(rect) = active_vp_rect {
// Clip the outline to the visible canvas. Clamping only the origin
// (max(0.0)) while keeping the full width/height shifted the whole
// outline inward when the viewport ran off the top/left edge, so
// its drawn border no longer matched the real viewport — clicks
// that looked outside landed in (and activated) another viewport.
let (cw, ch) = tab.scene.selection.borrow().vp_size;
let x = rect.x.max(0.0);
let y = rect.y.max(0.0);
let vw = ((rect.x + rect.width).min(cw) - x).max(1.0);
let vh = ((rect.y + rect.height).min(ch) - y).max(1.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(vw))
.height(iced::Length::Fixed(vh)),
)
.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.grid_snap(),
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<String> = self
.command_line
.cmd_recall
.iter()
.rev()
.take(3)
.cloned()
.collect();
viewport_stack = viewport_stack.push(viewport_context_menu_overlay(
p,
has_cmd,
has_selection,
isolation_active,
last_cmds,
draworder_open,
));
}
}
// In-place MText editor (toolbar + text area), anchored at the
// insertion-point click.
if !tab.is_start {
let canvas = tab.scene.selection.borrow().vp_size;
if let Some(ed) = &self.mtext_editor {
let styles: Vec<String> = tab
.scene
.document
.text_styles
.iter()
.map(|s| s.name.clone())
.collect();
viewport_stack = viewport_stack.push(mtext_editor_overlay(ed, styles, canvas));
}
if let Some(ed) = &self.text_inline {
viewport_stack = viewport_stack.push(text_inline_overlay(ed, canvas));
}
}
// Properties / layers panels carry no useful state on the Start tab.
// Replace the properties panel with a Recent Documents list there.
let properties_el: Element<'_, Message> = if tab.is_start {
recent_files_panel(&self.app_menu.recent)
} else if self.show_properties && !self.clean_screen {
tab.properties.view()
} else {
Space::new().into()
};
// Command-line sits as a bottom-centre overlay on top of the
// viewport stack rather than as a separate row in the main
// column — frees up vertical space when no command is active
// and keeps the input close to where the cursor is drawing.
// Autocomplete shows only when no command is collecting its
// own input (otherwise typed prefixes are coordinates / values).
let allow_autocomplete = tab.active_cmd.is_none();
// Dynamic input captures keystrokes when its fields are showing,
// so the command-line field must release focus / its on_input.
// The MText preview also captures keystrokes (typing edits it), so the
// command line must likewise release its on_input there.
let dyn_capturing =
(self.dyn_input && tab.active_cmd.is_some() && !tab.dyn_fields.is_empty())
|| self.mtext_editor.as_ref().is_some_and(|e| e.show_preview)
|| self.text_inline.is_some();
let command_line_overlay =
iced::widget::container(self.command_line.view(allow_autocomplete, dyn_capturing))
.width(Fill)
.height(Fill)
.align_x(iced::alignment::Horizontal::Center)
.align_y(iced::alignment::Vertical::Bottom)
.padding(iced::Padding {
top: 0.0,
right: 0.0,
bottom: 2.0,
left: 0.0,
});
let center_stack = iced::widget::stack![
row![properties_el, viewport_stack].width(Fill).height(Fill),
command_line_overlay,
]
.width(Fill)
.height(Fill);
let main_ui = container({
// Clean-screen mode drops the ribbon for a full-canvas view; the
// status bar stays so the mode can be toggled back off.
let mut col = column![];
if !self.clean_screen {
col = col.push(self.ribbon.view(
is_paper,
self.tabs[self.active_tab].history.undo_stack.len(),
self.tabs[self.active_tab].history.redo_stack.len(),
));
}
if self.show_file_tabs {
col = col.push(doc_tab_bar(&self.tabs, self.active_tab));
}
col.push(center_stack)
.push({
let is_model = tab.scene.current_layout == "Model";
let scale_pill_enabled = is_model
|| tab.scene.active_viewport.is_some()
|| tab.scene.has_selected_viewport();
// The cursor is tracked in local render space; re-add the
// model-space world offset so the readout shows true
// drawing coordinates (paper space carries no offset), then
// report it in the active UCS — the readout follows the
// user's coordinate system, not raw WCS (no-op without UCS).
let cursor_coord = {
let lc = tab.last_cursor_world;
// The readout follows the active pane's UCS — model space
// or inside a floating viewport (no-op without a UCS).
if tab.editing_model_space() {
tab.ucs_xform().to_ucs(lc)
} 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<String> = tab
.scene
.entity_type_names_in_layout()
.into_iter()
.map(|s| s.to_string())
.collect();
crate::ui::selection_filter_popup::selection_filter_popup_overlay(
types,
&tab.scene.selection_filter,
)
} else {
iced::widget::Space::new().width(0).height(0).into()
};
let dropdown_layer: Element<'_, Message> = self
.ribbon
.dropdown_overlay(
&history_dropdown_labels(&self.tabs[self.active_tab].history.undo_stack),
&history_dropdown_labels(&self.tabs[self.active_tab].history.redo_stack),
)
.unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into());
let layout_ctx_layer: Element<'_, Message> = if let Some(name) = &self.layout_context_menu {
layout_context_menu_overlay(name)
} else {
iced::widget::Space::new().width(0).height(0).into()
};
let qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect {
let types = tab.scene.entity_type_names_in_layout();
let properties = tab.scene.qselect_properties(state.type_filter.as_deref());
qselect_overlay(state, &types, &properties)
} else {
iced::widget::Space::new().width(0).height(0).into()
};
let open_progress_layer: Element<'_, Message> = if let Some(p) = &self.opening {
crate::ui::open_progress::view(p, iced::time::Instant::now())
} else {
iced::widget::Space::new().width(0).height(0).into()
};
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<Element<'_, Message>> {
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<String> = tab
.scene
.document
.text_styles
.iter()
.map(|s| s.name.clone())
.collect();
let (backward, upside_down, annotative) = tab
.scene
.document
.text_styles
.get(&self.textstyle_selected)
.map(|s| (s.flags.backward, s.flags.upside_down, s.annotative))
.unwrap_or((false, false, false));
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<String> = 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<String> = 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<String> = 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<String> = vec!["None".to_string()];
block_opts.extend(doc.block_records.iter().map(|b| b.name.clone()));
let mut lt_opts: Vec<String> = vec!["None".to_string()];
lt_opts.extend(doc.line_types.iter().map(|lt| lt.name.clone()));
let mut textstyle_opts: Vec<String> = vec!["None".to_string()];
textstyle_opts.extend(doc.text_styles.iter().map(|t| t.name.clone()));
let opt_block = |h: Option<acadrust::types::Handle>| -> String {
match h {
Some(h) => doc
.block_records
.iter()
.find(|b| b.handle == h)
.map(|b| b.name.clone())
.unwrap_or_else(|| "None".to_string()),
None => "None".to_string(),
}
};
let opt_lt = |h: Option<acadrust::types::Handle>| -> String {
match h {
Some(h) => doc
.line_types
.iter()
.find(|lt| lt.handle == h)
.map(|lt| lt.name.clone())
.unwrap_or_else(|| "None".to_string()),
None => "None".to_string(),
}
};
let opt_ts = |h: Option<acadrust::types::Handle>| -> String {
match h {
Some(h) => doc
.text_styles
.iter()
.find(|t| t.handle == h)
.map(|t| t.name.clone())
.unwrap_or_else(|| "None".to_string()),
None => "None".to_string(),
}
};
let (line_type_name, arrowhead_name, text_style_name, block_content_name) =
match selected_style {
Some(s) => (
opt_lt(s.line_type_handle),
opt_block(s.arrowhead_handle),
opt_ts(s.text_style_handle),
opt_block(s.block_content_handle),
),
None => Default::default(),
};
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<String> = tab
.scene
.document
.dim_styles
.iter()
.map(|s| s.name.clone())
.collect();
let doc = &tab.scene.document;
// Dropdown options (names must match the records exactly so the
// selection can be resolved back to a handle on the update side).
let mut block_opts: Vec<String> = vec!["Default".to_string()];
block_opts.extend(doc.block_records.iter().map(|b| b.name.clone()));
let mut lt_opts: Vec<String> = vec!["ByBlock".to_string()];
lt_opts.extend(doc.line_types.iter().map(|lt| lt.name.clone()));
let blk_name = |h: acadrust::types::Handle| -> String {
if h.is_null() {
"Default".to_string()
} else {
doc.block_records
.iter()
.find(|b| b.handle == h)
.map(|b| b.name.clone())
.unwrap_or_else(|| "Default".to_string())
}
};
let lt_name = |h: acadrust::types::Handle| -> String {
if h.is_null() {
"ByBlock".to_string()
} else {
doc.line_types
.iter()
.find(|lt| lt.handle == h)
.map(|lt| lt.name.clone())
.unwrap_or_else(|| "ByBlock".to_string())
}
};
let ds_sel = doc.dim_styles.get(&self.dimstyle_selected);
let (
dimblk_name,
dimblk1_name,
dimblk2_name,
dimldrblk_name,
dimltex_name,
dimltex1_name,
dimltex2_name,
) = match ds_sel {
Some(d) => (
blk_name(d.dimblk),
blk_name(d.dimblk1),
blk_name(d.dimblk2),
blk_name(d.dimldrblk),
lt_name(d.dimltex_handle),
lt_name(d.dimltex1_handle),
lt_name(d.dimltex2_handle),
),
None => Default::default(),
};
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<Message> {
use iced::event;
// Only request per-frame ticks while something on screen is animating
// (currently just the open-progress indicator). Without this gate the
// app burned 2-3% CPU continuously redrawing an unchanged view.
// See #18.
let needs_frames = self.opening.is_some();
let frames = if needs_frames {
window::frames().map(Message::Tick)
} else {
Subscription::none()
};
// While the command-line overlay is still displaying any
// recently-pushed history entry, re-render every frame so the
// entry disappears at the moment its visible window expires.
// The subscription auto-stops once no entry is fresh enough
// (typically within a few seconds of the last command).
let history_tick = if self.command_line.has_visible_history() {
window::frames().map(Message::Tick)
} else {
Subscription::none()
};
// While the cursor sits over a grip, request animation frames
// so the multi-functional popup opens even when the user keeps
// the mouse perfectly still — `ViewportMove` alone would never
// fire again. Auto-stops once the hover clears or the popup is
// already open.
let grip_dwell = if self.grip_hover.is_some() && self.grip_popup.is_none() {
window::frames().map(|_| Message::GripDwellTick)
} else {
Subscription::none()
};
// 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<Message> {
iced::widget::operation::focus(iced::widget::Id::new(crate::ui::command_line::CMD_INPUT_ID))
}
}
// ── Document tab bar ───────────────────────────────────────────────────────
pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Element<'a, Message> {
const BAR_BG: Color = Color {
r: 0.13,
g: 0.13,
b: 0.13,
a: 1.0,
};
const TAB_ACTIVE: Color = Color {
r: 0.22,
g: 0.22,
b: 0.22,
a: 1.0,
};
const TAB_HOVER: Color = Color {
r: 0.18,
g: 0.18,
b: 0.18,
a: 1.0,
};
const TAB_INACTIVE: Color = Color {
r: 0.13,
g: 0.13,
b: 0.13,
a: 1.0,
};
const ACCENT: Color = Color {
r: 0.20,
g: 0.55,
b: 0.90,
a: 1.0,
};
const TEXT_ACTIVE: Color = Color::WHITE;
const TEXT_INACTIVE: Color = Color {
r: 0.60,
g: 0.60,
b: 0.60,
a: 1.0,
};
const CLOSE_HOVER: Color = Color {
r: 0.70,
g: 0.22,
b: 0.22,
a: 1.0,
};
const BORDER_COLOR: Color = Color {
r: 0.25,
g: 0.25,
b: 0.25,
a: 1.0,
};
let mut bar = Row::new().spacing(0).align_y(iced::Center);
for (idx, tab) in tabs.iter().enumerate() {
let is_active = idx == active_tab;
let name = crate::ui::text_util::elide(&tab.tab_display_name(), 24);
let 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<crate::entities::text_support::GlyphBox>,
/// Current selection as a visible-char range.
sel: Option<(usize, usize)>,
/// Caret position as a visible-char offset.
caret: usize,
/// Whether the caret is in its visible blink phase.
caret_on: bool,
/// World-space min corner (bbox) and pixels-per-world-unit scale.
minx: f32,
miny: f32,
scale: f32,
content_h: f32,
}
impl MTextPreview {
/// Visible-char offset (0..=N) nearest the cursor point (bounds-local px).
fn offset_at(&self, p: iced::Point) -> usize {
if self.boxes.is_empty() {
return 0;
}
let wx = self.minx + (p.x - MTEXT_PREVIEW_PAD) / self.scale;
let wy = self.miny + (self.content_h - p.y - MTEXT_PREVIEW_PAD) / self.scale;
let mut best = 0usize;
let mut best_d = f32::MAX;
for b in &self.boxes {
let dx = if wx < b.xmin {
b.xmin - wx
} else if wx > b.xmax {
wx - b.xmax
} else {
0.0
};
let dy = if wy < b.ymin {
b.ymin - wy
} else if wy > b.ymax {
wy - b.ymax
} else {
0.0
};
let d = dy * 1000.0 + dx; // prefer the correct line first
if d < best_d {
best_d = d;
best = b.vis;
// After the glyph centre → caret sits after this char.
if wx > (b.xmin + b.xmax) * 0.5 {
best = b.vis + 1;
}
}
}
best
}
}
#[derive(Default)]
struct MTextPreviewState {
dragging: bool,
}
impl iced::widget::canvas::Program<Message> for MTextPreview {
type State = MTextPreviewState;
fn update(
&self,
state: &mut MTextPreviewState,
event: &iced::Event,
bounds: iced::Rectangle,
cursor: iced::mouse::Cursor,
) -> Option<iced::widget::canvas::Action<Message>> {
use iced::mouse::{Button, Event as Me};
use iced::widget::canvas::Action;
use iced::Event;
match event {
Event::Mouse(Me::ButtonPressed(Button::Left)) => {
if let Some(p) = cursor.position_in(bounds) {
state.dragging = true;
let off = self.offset_at(p);
return Some(Action::publish(Message::MTextSelStart(off)).and_capture());
}
}
Event::Mouse(Me::CursorMoved { .. }) => {
if state.dragging {
if let Some(p) = cursor.position_in(bounds) {
let off = self.offset_at(p);
return Some(Action::publish(Message::MTextSelTo(off)));
}
}
}
Event::Mouse(Me::ButtonReleased(Button::Left)) => {
if state.dragging {
state.dragging = false;
return Some(Action::capture());
}
}
_ => {}
}
None
}
fn draw(
&self,
_state: &MTextPreviewState,
renderer: &iced::Renderer,
_theme: &Theme,
bounds: iced::Rectangle,
_cursor: iced::mouse::Cursor,
) -> Vec<iced::widget::canvas::Geometry> {
use iced::widget::canvas::{Frame, Path, Stroke};
let mut frame = Frame::new(renderer, bounds.size());
let pad = MTEXT_PREVIEW_PAD;
// Draw at the real size; flip Y (world up → screen down).
let map = |x: f32, y: f32| {
iced::Point::new(
pad + (x - self.minx) * self.scale,
self.content_h - (pad + (y - self.miny) * self.scale),
)
};
// Selection highlight behind the glyphs.
if let Some((a, b)) = self.sel {
for bx in &self.boxes {
if bx.vis >= a && bx.vis < b {
let p0 = map(bx.xmin, bx.ymax);
let p1 = map(bx.xmax, bx.ymin);
let rect = Path::rectangle(
iced::Point::new(p0.x.min(p1.x), p0.y.min(p1.y)),
iced::Size::new((p1.x - p0.x).abs(), (p1.y - p0.y).abs()),
);
frame.fill(
&rect,
Color {
r: 0.20,
g: 0.42,
b: 0.72,
a: 0.45,
},
);
}
}
}
for (seg, col) in &self.segments {
if seg.len() < 2 {
continue;
}
let path = Path::new(|p| {
p.move_to(map(seg[0].0, seg[0].1));
for &(x, y) in &seg[1..] {
p.line_to(map(x, y));
}
});
frame.stroke(&path, Stroke::default().with_color(*col).with_width(1.4));
}
// Caret — a vertical bar at the caret's glyph boundary, shown when the
// selection is empty (a plain text cursor).
// Caret is shown only when the selection is empty and the blink is in
// its visible phase.
let collapsed = self.caret_on && self.sel.map(|(a, b)| a == b).unwrap_or(true);
if collapsed && self.boxes.is_empty() {
// Empty text: show a caret at the top-left so the user can type.
let path = Path::new(|p| {
p.move_to(iced::Point::new(MTEXT_PREVIEW_PAD, MTEXT_PREVIEW_PAD));
p.line_to(iced::Point::new(
MTEXT_PREVIEW_PAD,
(MTEXT_PREVIEW_PAD + 22.0).min(self.content_h),
));
});
frame.stroke(
&path,
Stroke::default()
.with_color(Color {
r: 0.95,
g: 0.95,
b: 0.55,
a: 1.0,
})
.with_width(1.5),
);
} else if collapsed {
let bar = if let Some(b) = self.boxes.iter().find(|b| b.vis == self.caret) {
Some((b.xmin, b.ymin, b.ymax)) // left edge of the caret's glyph
} else if self.caret > 0 {
self.boxes
.iter()
.find(|b| b.vis == self.caret - 1)
.map(|b| (b.xmax, b.ymin, b.ymax)) // after the last glyph
} else {
self.boxes.first().map(|b| (b.xmin, b.ymin, b.ymax))
};
if let Some((cx, y0, y1)) = bar {
let p0 = map(cx, y0);
let p1 = map(cx, y1);
let path = Path::new(|p| {
p.move_to(p0);
p.line_to(p1);
});
frame.stroke(
&path,
Stroke::default()
.with_color(Color {
r: 0.95,
g: 0.95,
b: 0.55,
a: 1.0,
})
.with_width(1.5),
);
}
}
vec![frame.into_geometry()]
}
}
/// Split every preview WireModel into finite (x, y) polyline runs, each
/// carrying its wire's colour so inline `\C` / the colour dropdown shows.
fn mtext_preview_segments(
ed: &super::mtext_editor::MTextEditorState,
) -> Vec<(Vec<(f32, f32)>, Color)> {
let mut out: Vec<(Vec<(f32, f32)>, Color)> = Vec::new();
for w in &ed.preview_wires {
let col = Color {
r: w.color[0],
g: w.color[1],
b: w.color[2],
a: 1.0,
};
let mut run: Vec<(f32, f32)> = Vec::new();
for p in &w.points {
if p[0].is_finite() && p[1].is_finite() {
run.push((p[0], p[1]));
} else if !run.is_empty() {
out.push((std::mem::take(&mut run), col));
}
}
if !run.is_empty() {
out.push((run, col));
}
}
out
}
fn mtext_editor_overlay<'a>(
ed: &'a super::mtext_editor::MTextEditorState,
styles: Vec<String>,
canvas_size: (f32, f32),
) -> Element<'a, Message> {
use super::mtext_editor::{JustifyChoice, MTextFmt, ParaAlign};
use iced::widget::{canvas, svg, text_editor};
const PANEL_BG: Color = Color {
r: 0.16,
g: 0.16,
b: 0.16,
a: 0.98,
};
const BORDER: Color = Color {
r: 0.40,
g: 0.40,
b: 0.40,
a: 1.0,
};
const TEXT_COL: Color = Color {
r: 0.88,
g: 0.88,
b: 0.88,
a: 1.0,
};
const FIELD_BG: Color = Color {
r: 0.12,
g: 0.12,
b: 0.12,
a: 1.0,
};
let btn_style = |_: &Theme, status: button::Status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => Color {
r: 0.28,
g: 0.40,
b: 0.55,
a: 1.0,
},
_ => Color {
r: 0.22,
g: 0.22,
b: 0.22,
a: 1.0,
},
})),
text_color: TEXT_COL,
border: Border {
color: BORDER,
width: 1.0,
radius: 3.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
};
let icon_btn = move |bytes: &'static [u8], msg: Message| -> Element<'static, Message> {
button(svg(svg::Handle::from_memory(bytes)).width(18).height(18))
.on_press(msg)
.padding(3)
.style(btn_style)
.into()
};
let lbl = |s: &'static str| text(s).size(11).color(TEXT_COL);
let small_input = |placeholder: &'static str,
val: &str,
on: fn(String) -> Message,
w: f32|
-> Element<'static, Message> {
text_input(placeholder, val)
.on_input(on)
.width(iced::Length::Fixed(w))
.padding(3)
.size(12)
.into()
};
// ── Row 1: style / font / height · format icons · colour ──────────────
let style_opts: Vec<String> = if styles.is_empty() {
vec!["Standard".to_string()]
} else {
styles
};
let style_pl = pick_list(style_opts, Some(ed.style.clone()), Message::MTextStyle)
.text_size(11)
.width(iced::Length::Fixed(96.0));
let font_sel = if ed.font.trim().is_empty() {
"[Style default]".to_string()
} else {
ed.font.clone()
};
let font_pl = pick_list(
MTEXT_FONTS
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
Some(font_sel),
Message::MTextFont,
)
.text_size(11)
.width(iced::Length::Fixed(120.0));
let color_sel = MTEXT_COLORS
.iter()
.find(|(_, a)| *a == ed.color_aci)
.map(|(n, _)| n.to_string())
.unwrap_or_else(|| "ByLayer".to_string());
let color_pl = pick_list(
MTEXT_COLORS
.iter()
.map(|(n, _)| n.to_string())
.collect::<Vec<_>>(),
Some(color_sel),
|name: String| {
let aci = MTEXT_COLORS
.iter()
.find(|(n, _)| *n == name)
.map(|(_, a)| *a)
.unwrap_or(256);
Message::MTextColor(aci)
},
)
.text_size(11)
.width(iced::Length::Fixed(96.0));
let row1 = row![
style_pl,
font_pl,
small_input("2.5", &ed.height, Message::MTextHeight, 64.0),
iced::widget::Space::new().width(6),
icon_btn(
include_bytes!("../../assets/icons/mt_bold.svg"),
Message::MTextFmt(MTextFmt::Bold)
),
icon_btn(
include_bytes!("../../assets/icons/mt_italic.svg"),
Message::MTextFmt(MTextFmt::Italic)
),
icon_btn(
include_bytes!("../../assets/icons/mt_underline.svg"),
Message::MTextFmt(MTextFmt::Underline)
),
icon_btn(
include_bytes!("../../assets/icons/mt_overline.svg"),
Message::MTextFmt(MTextFmt::Overline)
),
icon_btn(
include_bytes!("../../assets/icons/mt_strike.svg"),
Message::MTextFmt(MTextFmt::Strike)
),
icon_btn(
include_bytes!("../../assets/icons/mt_upper.svg"),
Message::MTextFmt(MTextFmt::Uppercase)
),
icon_btn(
include_bytes!("../../assets/icons/mt_lower.svg"),
Message::MTextFmt(MTextFmt::Lowercase)
),
iced::widget::Space::new().width(Fill),
color_pl,
]
.spacing(4)
.align_y(iced::Alignment::Center);
// ── Row 2: oblique / width / char-spacing · align · line spacing · OK ─
let justify = pick_list(
JustifyChoice::ALL,
Some(JustifyChoice(ed.attachment)),
|c| Message::MTextJustify(c.0),
)
.text_size(11)
.width(iced::Length::Fixed(112.0));
let row2 = row![
lbl("O"),
small_input("0", &ed.oblique, Message::MTextOblique, 48.0),
lbl("W"),
small_input("1", &ed.width, Message::MTextWidth, 48.0),
lbl("◊"),
small_input("0", &ed.char_space, Message::MTextCharSpace, 48.0),
iced::widget::Space::new().width(6),
icon_btn(
include_bytes!("../../assets/icons/mt_align_left.svg"),
Message::MTextAlign(ParaAlign::Left)
),
icon_btn(
include_bytes!("../../assets/icons/mt_align_center.svg"),
Message::MTextAlign(ParaAlign::Center)
),
icon_btn(
include_bytes!("../../assets/icons/mt_align_right.svg"),
Message::MTextAlign(ParaAlign::Right)
),
icon_btn(
include_bytes!("../../assets/icons/mt_align_justify.svg"),
Message::MTextAlign(ParaAlign::Justify)
),
iced::widget::Space::new().width(6),
justify,
lbl("LS"),
button(lbl("1"))
.on_press(Message::MTextLineSpacing(1.0))
.padding(3)
.style(btn_style),
button(lbl("1.5"))
.on_press(Message::MTextLineSpacing(1.5))
.padding(3)
.style(btn_style),
button(lbl("2"))
.on_press(Message::MTextLineSpacing(2.0))
.padding(3)
.style(btn_style),
iced::widget::Space::new().width(Fill),
icon_btn(
include_bytes!("../../assets/icons/mt_ok.svg"),
Message::MTextOk
),
icon_btn(
include_bytes!("../../assets/icons/mt_cancel.svg"),
Message::MTextCancel
),
]
.spacing(4)
.align_y(iced::Alignment::Center);
// ── Segmented Edit | Preview toggle (between toolbar and body) ────────
let seg_btn =
move |label: &'static str, active: bool, on: Message| -> Element<'static, Message> {
button(text(label).size(12).color(if active {
Color::WHITE
} else {
Color {
r: 0.80,
g: 0.80,
b: 0.80,
a: 1.0,
}
}))
.on_press(on)
.padding([4, 14])
.style(move |_: &Theme, _| button::Style {
background: Some(Background::Color(if active {
Color {
r: 0.20,
g: 0.42,
b: 0.72,
a: 1.0,
}
} else {
Color {
r: 0.20,
g: 0.20,
b: 0.20,
a: 1.0,
}
})),
text_color: TEXT_COL,
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
})
.into()
};
let toggle = container(
row![
seg_btn("Edit", !ed.show_preview, Message::MTextShowPreview(false)),
seg_btn("Preview", ed.show_preview, Message::MTextShowPreview(true)),
]
.spacing(0),
)
.padding([6, 0]);
// ── Body: toggles between raw code input and rendered preview ────────
const VIEW_H: f32 = 150.0;
let body: Element<'a, Message> = if ed.show_preview {
let segments = mtext_preview_segments(ed);
let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for (seg, _) in &segments {
for &(x, y) in seg {
minx = minx.min(x);
miny = miny.min(y);
maxx = maxx.max(x);
maxy = maxy.max(y);
}
}
// Include glyph boxes so all-whitespace / box-only lines still anchor
// the transform (hit-testing relies on minx/miny).
for b in &ed.glyph_boxes {
minx = minx.min(b.xmin);
miny = miny.min(b.ymin);
maxx = maxx.max(b.xmax);
maxy = maxy.max(b.ymax);
}
let h_unit = ed.height_value() as f32;
// Real text size: fixed pixels per em so more/taller text grows the
// canvas (and scrolls) instead of shrinking to fit.
let scale = (22.0 / h_unit.max(1e-3)).clamp(2.0, 600.0);
let content_h = if maxx >= minx {
((maxy - miny) * scale + 2.0 * MTEXT_PREVIEW_PAD).max(40.0)
} else {
40.0
};
let prog = MTextPreview {
segments,
boxes: ed.glyph_boxes.clone(),
sel: ed.sel,
caret: ed.caret,
caret_on: ed.caret_blink_on,
minx,
miny,
scale,
content_h,
};
let cv = canvas(prog)
.width(Fill)
.height(iced::Length::Fixed(content_h));
container(iced::widget::scrollable(cv).height(iced::Length::Fixed(VIEW_H)))
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(FIELD_BG)),
border: Border {
color: BORDER,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.padding(2)
.width(Fill)
.into()
} else {
text_editor(&ed.content)
.id(iced::widget::Id::new(MTEXT_TEXT_ID))
.on_action(Message::MTextEdit)
.height(iced::Length::Fixed(VIEW_H))
.padding(6)
.size(13)
.into()
};
let panel = container(column![row1, row2, toggle, body].spacing(5))
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border {
color: BORDER,
width: 1.0,
radius: 5.0.into(),
},
..Default::default()
})
.padding(6)
.width(iced::Length::Fixed(640.0));
// Keep the whole panel on-screen: clamp the anchor so it never spills past
// the right/bottom edge (where its toolbar buttons would be unclickable).
// Width is fixed; height is the toolbar rows + the fixed VIEW_H body.
const PANEL_W: f32 = 640.0 + 14.0; // fixed width + padding/border
const PANEL_H: f32 = VIEW_H + 150.0; // body + toolbars/toggle/padding
let (cw, ch) = canvas_size;
let anchor = iced::Point::new(
(ed.screen_anchor.x - 10.0).clamp(0.0, (cw - PANEL_W).max(0.0)),
(ed.screen_anchor.y - 90.0).clamp(0.0, (ch - PANEL_H).max(0.0)),
);
position_canvas_overlay(anchor, panel.into())
}
// ── Viewport right-click context menu ──────────────────────────────────────
fn viewport_context_menu_overlay(
pos: iced::Point,
has_cmd: bool,
has_selection: bool,
isolation_active: bool,
last_cmds: Vec<String>,
draworder_open: bool,
) -> Element<'static, Message> {
const MENU_BG: Color = Color {
r: 0.17,
g: 0.17,
b: 0.17,
a: 1.0,
};
const MENU_BORDER: Color = Color {
r: 0.35,
g: 0.35,
b: 0.35,
a: 1.0,
};
const ITEM_HOVER: Color = Color {
r: 0.25,
g: 0.45,
b: 0.70,
a: 1.0,
};
const TEXT_COL: Color = Color {
r: 0.88,
g: 0.88,
b: 0.88,
a: 1.0,
};
const SEP_COL: Color = Color {
r: 0.30,
g: 0.30,
b: 0.30,
a: 1.0,
};
let item = |label: String, msg: Message| -> Element<'static, Message> {
button(text(label).size(12).color(TEXT_COL))
.on_press(msg)
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => ITEM_HOVER,
_ => Color::TRANSPARENT,
})),
text_color: TEXT_COL,
border: Border::default(),
shadow: iced::Shadow::default(),
snap: false,
})
.padding([4, 12])
.width(Fill)
.into()
};
let sep = || -> Element<'static, Message> {
container(iced::widget::Space::new().width(Fill).height(1))
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(SEP_COL)),
..Default::default()
})
.width(Fill)
.height(1)
.padding([0, 6])
.into()
};
// Indented variant for sub-menu rows (e.g. Draw Order children).
let subitem = |label: String, msg: Message| -> Element<'static, Message> {
button(text(label).size(12).color(TEXT_COL))
.on_press(msg)
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => ITEM_HOVER,
_ => Color::TRANSPARENT,
})),
text_color: TEXT_COL,
border: Border::default(),
shadow: iced::Shadow::default(),
snap: false,
})
.padding(iced::Padding {
top: 4.0,
right: 12.0,
bottom: 4.0,
left: 26.0,
})
.width(Fill)
.into()
};
let mut items: Vec<Element<'static, Message>> = Vec::new();
if has_cmd {
items.push(item("Cancel".to_string(), Message::CommandEscape));
items.push(item("Enter".to_string(), Message::CommandFinalize));
} else {
if !last_cmds.is_empty() {
let last = last_cmds[0].clone();
items.push(item(
format!("Repeat {last}"),
Message::Command(last.to_uppercase()),
));
if last_cmds.len() > 1 {
for cmd in last_cmds.iter().skip(1) {
let c = cmd.clone();
items.push(item(c.clone(), Message::Command(c.to_uppercase())));
}
}
items.push(sep());
}
if has_selection {
items.push(item("Delete".to_string(), Message::DeleteSelected));
items.push(item(
"Move".to_string(),
Message::Command("MOVE".to_string()),
));
items.push(item(
"Copy".to_string(),
Message::Command("COPY".to_string()),
));
items.push(sep());
let 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<String> = vec![QSELECT_ANY_TYPE.to_string()];
type_options.extend(types.iter().map(|s| (*s).to_string()));
let mut prop_options: Vec<crate::app::QSelectPropertyChoice> =
vec![crate::app::QSelectPropertyChoice {
field: String::new(),
label: QSELECT_ANY_PROP.to_string(),
}];
prop_options.extend(properties.iter().map(|(field, label)| {
crate::app::QSelectPropertyChoice {
field: field.clone(),
label: label.clone(),
}
}));
let op_options: Vec<crate::app::QSelectOp> = vec![
crate::app::QSelectOp::Eq,
crate::app::QSelectOp::Neq,
crate::app::QSelectOp::Gt,
crate::app::QSelectOp::Lt,
crate::app::QSelectOp::Any,
];
let type_sel = state
.type_filter
.clone()
.unwrap_or_else(|| QSELECT_ANY_TYPE.to_string());
let prop_sel = state
.property
.clone()
.unwrap_or(crate::app::QSelectPropertyChoice {
field: String::new(),
label: QSELECT_ANY_PROP.to_string(),
});
// The value field is disabled (visually de-emphasised; we still
// render the same widget) when no property is picked or the
// operator is "*Any value" — both of those skip the value test.
let value_enabled =
state.property.is_some() && !matches!(state.operator, crate::app::QSelectOp::Any);
let label = |s: &'static str| {
text(s)
.size(12)
.color(TEXT)
.width(iced::Length::Fixed(90.0))
};
let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| {
button(text(lbl).size(12).color(TEXT))
.on_press(msg)
.style(move |_: &Theme, st| button::Style {
background: Some(Background::Color(
if matches!(st, button::Status::Hovered | button::Status::Pressed) {
hov
} else {
base
},
)),
text_color: TEXT,
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.padding([4, 14])
};
let mut value_input = text_input("", &state.value).size(12);
if value_enabled {
value_input = value_input.on_input(Message::QSelectSetValue);
}
let panel_body = column![
text("Quick Select").size(14).color(TEXT),
Space::new().height(10),
row![
label("Object type:"),
pick_list(type_options, Some(type_sel), |s: String| {
if s == QSELECT_ANY_TYPE {
Message::QSelectSetType(None)
} else {
Message::QSelectSetType(Some(s))
}
})
.width(Fill),
]
.align_y(iced::Alignment::Center)
.spacing(8),
Space::new().height(6),
row![
label("Property:"),
pick_list(
prop_options,
Some(prop_sel),
|p: crate::app::QSelectPropertyChoice| {
if p.field.is_empty() {
Message::QSelectSetProperty(None)
} else {
Message::QSelectSetProperty(Some(p))
}
}
)
.width(Fill),
]
.align_y(iced::Alignment::Center)
.spacing(8),
Space::new().height(6),
row![
label("Operator:"),
pick_list(
op_options,
Some(state.operator),
Message::QSelectSetOperator
)
.width(Fill),
]
.align_y(iced::Alignment::Center)
.spacing(8),
Space::new().height(6),
row![label("Value:"), value_input,]
.align_y(iced::Alignment::Center)
.spacing(8),
Space::new().height(10),
row![
checkbox(state.append)
.on_toggle(Message::QSelectSetAppend)
.size(14),
Space::new().width(6),
text("Append to current selection").size(12).color(TEXT),
]
.align_y(iced::Alignment::Center),
Space::new().height(14),
row![
Space::new().width(Fill),
btn("Cancel", Message::QSelectClose, BTN_BG, BTN_HOV),
Space::new().width(8),
btn("Apply", Message::QSelectApply, BTN_OK, BTN_OK_HOV),
]
.align_y(iced::Alignment::Center),
]
.spacing(0);
let panel = container(panel_body)
.padding(16)
.width(iced::Length::Fixed(400.0))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
border: Border {
color: BORDER,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
});
// Outside-click catcher — fills the whole screen, sits below the
// panel. The panel itself is rendered above and absorbs its own
// clicks via standard widget event handling.
let catcher = mouse_area(
container(iced::widget::Space::new().width(Fill).height(Fill))
.width(Fill)
.height(Fill),
)
.on_press(Message::QSelectClose)
.on_right_press(Message::QSelectClose);
let centered = container(iced::widget::opaque(panel)).center(Fill);
stack![catcher, centered].into()
}
/// Content for the floating "Unsaved Changes" OS window.
const SAVE_FORMAT_OPTIONS: &[&str] = &[
"DWG 2018", "DWG 2013", "DWG 2010", "DWG 2007", "DWG 2004", "DWG 2000", "DWG R14", "DXF 2018",
"DXF 2013", "DXF 2010", "DXF 2007", "DXF 2004", "DXF 2000", "DXF R14",
];
fn save_as_dialog_window<'a>(
filename: &'a str,
folder: &'a std::path::Path,
entries: &'a [(String, bool, std::path::PathBuf)],
format: &'a str,
) -> Element<'a, Message> {
const BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.17,
a: 1.0,
};
const LIST_BG: Color = Color {
r: 0.11,
g: 0.11,
b: 0.13,
a: 1.0,
};
const BORDER: Color = Color {
r: 0.32,
g: 0.32,
b: 0.36,
a: 1.0,
};
const TEXT: Color = Color {
r: 0.90,
g: 0.90,
b: 0.90,
a: 1.0,
};
const DIM: Color = Color {
r: 0.58,
g: 0.58,
b: 0.62,
a: 1.0,
};
const INPUT_BG: Color = Color {
r: 0.10,
g: 0.10,
b: 0.12,
a: 1.0,
};
const BTN_OK: Color = Color {
r: 0.20,
g: 0.46,
b: 0.80,
a: 1.0,
};
const BTN_HOV: Color = Color {
r: 0.26,
g: 0.55,
b: 0.92,
a: 1.0,
};
const BTN_GREY: Color = Color {
r: 0.26,
g: 0.26,
b: 0.29,
a: 1.0,
};
const BTN_GHOV: Color = Color {
r: 0.34,
g: 0.34,
b: 0.38,
a: 1.0,
};
const DIR_COL: Color = Color {
r: 0.75,
g: 0.85,
b: 1.00,
a: 1.0,
};
const FILE_COL: Color = TEXT;
const ROW_HOV: Color = Color {
r: 0.22,
g: 0.24,
b: 0.28,
a: 1.0,
};
let input_sty =
|_: &Theme, _: iced::widget::text_input::Status| iced::widget::text_input::Style {
background: Background::Color(INPUT_BG),
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
icon: TEXT,
placeholder: DIM,
value: TEXT,
selection: Color {
r: 0.20,
g: 0.46,
b: 0.80,
a: 0.45,
},
};
let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| {
button(text(lbl).size(12).color(TEXT))
.on_press(msg)
.style(move |_: &Theme, st| button::Style {
background: Some(Background::Color(
if matches!(st, button::Status::Hovered | button::Status::Pressed) {
hov
} else {
base
},
)),
text_color: TEXT,
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.padding([4, 12])
};
// ── Path bar ─────────────────────────────────────────────────────────
let path_str = if crate::io::is_drives_root(folder) {
"This PC".to_string()
} else {
folder.to_string_lossy().into_owned()
};
let up_path = crate::io::parent_folder(folder);
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<Element<'_, Message>> = 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);
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
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<RenderModeChoice> = 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<glam::Vec3>,
xf: &super::helpers::UcsXform,
) -> String {
if let Some(b) = &f.buffer {
return b.clone();
}
let b = base.unwrap_or(glam::Vec3::ZERO);
// Relative deltas and the polar angle read in the active UCS plane. The
// delta is offset-invariant, so only the axis rotation matters (identity
// xf reproduces the world-frame deltas).
let d = xf.vec_to_ucs(w - b);
let dx = d.x as f64;
let dy = d.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(),
}
}