forked from 1b-agent/opencadstudio-upstream
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]>
4458 lines
165 KiB
Rust
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(),
|
|
}
|
|
}
|