//! Viewport overlay widgets. use glam::{Mat4, Vec3}; use iced::mouse; use iced::widget::canvas; use iced::{Color, Element, Length, Point, Size, Theme}; use crate::app::Message; use crate::scene::model::object::GripShape; use crate::scene::SelectionState; /// Half-size of the crosshair center square in screen pixels (square = SQ*2 × SQ*2). pub const CROSSHAIR_SQ: f32 = 7.5; /// Arm length of the crosshair from center — used as the snap aperture radius. pub const CROSSHAIR_ARM: f32 = 60.0; use crate::snap::SnapType; // ── Grip marker data ────────────────────────────────────────────────────── /// Describes one grip to be drawn in the viewport overlay. #[derive(Clone, Debug)] pub struct GripMarker { /// Screen-space position (viewport-relative pixels). pub pos: Point, /// Explicit marker shape. pub shape: GripShape, /// True → grip is currently being dragged (drawn filled red). pub is_hot: bool, /// World-XY direction vector — only consumed by the `Rectangle` /// shape to orient the box along its segment. `None` for grips /// that don't need rotation. pub dir: Option<[f32; 2]>, } // ── Grid display params ─────────────────────────────────────────────────── /// Which world-space plane the grid is drawn on — switches with camera angle. #[derive(Clone, Copy, PartialEq)] pub enum GridPlane { /// Horizontal XY plane (Z = 0). Default top-down view (Z-up). Xy, /// Vertical XZ plane (Y = 0). Front/back view. Xz, /// Vertical YZ plane (X = 0). Side view. Yz, } /// Passed to the canvas when the GRID display is active. #[derive(Clone)] pub struct GridParams { /// Rotation-only view-projection (Camera::view_proj_rte). Grid points are /// made relative to `eye` in f64 before projecting, so the grid stays /// precise / jitter-free at UTM-scale absolute coordinates. pub view_rot: Mat4, /// Camera eye in absolute world f64 — subtracted from each grid point. pub eye: glam::DVec3, pub bounds: iced::Rectangle, pub plane: GridPlane, /// Grid origin in absolute world f64 and the active UCS axis directions. /// The grid rules along these instead of world X/Y/Z, so it aligns to the /// user's coordinate system. Plain WCS passes `(ZERO, X, Y, Z)`. pub origin: glam::DVec3, pub axes: (Vec3, Vec3, Vec3), } /// Compute the adaptive grid step size (world units) that the grid renderer /// would use for a given view-projection matrix and viewport bounds. /// /// Returns the smallest power-of-5 multiple of 1.0 that places grid lines at /// least `MIN_GRID_PX` pixels apart. This matches exactly what `draw_grid` /// renders, so callers can sync snap spacing to the visible grid. pub fn compute_grid_step(view_rot: Mat4, bounds: iced::Rectangle) -> f32 { use glam::Vec3; // Only the per-unit screen scale is needed; project small eye-relative // offsets (0 / X / Y) through the rotation-only matrix so this stays correct // and precise at any absolute coordinate (no eye term required). let w2s = |world: Vec3| { let ndc = view_rot.project_point3(world); glam::Vec2::new( (ndc.x + 1.0) * 0.5 * bounds.width, (1.0 - ndc.y) * 0.5 * bounds.height, ) }; let o = w2s(Vec3::ZERO); let a1 = w2s(Vec3::X); let a2 = w2s(Vec3::Y); let d1 = (a1 - o).length(); let d2 = (a2 - o).length(); let px_per_unit = d1.max(d2); if px_per_unit < 1e-6 { return 1.0; } let mut s = 1.0_f32; while s * px_per_unit < MIN_GRID_PX { s *= 5.0; if s > 1e9 { return 1.0; } } s } /// Parameters for the screen-space UCS icon drawn in the viewport corner. pub struct UcsIconParams { /// View-projection matrix used to project world axis directions to screen. pub view_proj: Mat4, /// Viewport bounds (used for NDC → pixel conversion). pub bounds: iced::Rectangle, /// The active UCS axis directions in world space (X, Y, Z). Plain WCS is /// `(Vec3::X, Vec3::Y, Vec3::Z)`; a UCS rotates the tripod to match. pub axes: (Vec3, Vec3, Vec3), } // ── Selection overlay ─────────────────────────────────────────────────── /// An acquired OST tracking point with its screen position. #[derive(Clone, Debug)] pub struct OstTrackPoint { pub screen: Point, } pub fn selection_overlay<'a>( selection: SelectionState, snap: Option<(Point, SnapType)>, grips: Vec, grid: Vec, ucs_icon: Option, ost_points: Vec, cursor_screen: Point, show_viewcube: bool, tile_edges: Vec, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, snap, grips, grid, ucs_icon, ost_points, cursor_screen, show_viewcube, tile_edges, }) .width(Length::Fill) .height(Length::Fill) .into() } struct SelectionCanvas { selection: SelectionState, snap: Option<(Point, SnapType)>, grips: Vec, grid: Vec, ucs_icon: Option, ost_points: Vec, cursor_screen: Point, show_viewcube: bool, tile_edges: Vec, } impl SelectionCanvas { /// Returns the orientation of the Model-tile divider the cursor sits /// on (within a few pixels of perpendicular distance, inside the /// edge's span). Used by both `mouse_interaction` (to pick a resize /// cursor) and `draw` (to suppress the CAD crosshair). fn tile_edge_under( &self, cursor: mouse::Cursor, bounds: iced::Rectangle, ) -> Option { const TOL_PX: f32 = 4.0; let pos = cursor.position_in(bounds)?; for e in &self.tile_edges { let (perp, edge_px, span_lo, span_hi, pos_along) = match e.orient { crate::scene::TileEdgeOrient::Vertical => ( pos.x, e.coord * bounds.width, e.span.0 * bounds.height, e.span.1 * bounds.height, pos.y, ), crate::scene::TileEdgeOrient::Horizontal => ( pos.y, e.coord * bounds.height, e.span.0 * bounds.width, e.span.1 * bounds.width, pos.x, ), }; if (perp - edge_px).abs() <= TOL_PX && pos_along >= span_lo && pos_along <= span_hi { return Some(e.orient); } } None } } impl canvas::Program for SelectionCanvas { type State = (); fn mouse_interaction( &self, _state: &(), bounds: iced::Rectangle, cursor: mouse::Cursor, ) -> mouse::Interaction { if self.show_viewcube { if let Some(pos) = cursor.position_in(bounds) { use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD}; let vc_x = bounds.width - VIEWCUBE_DRAW_PX - VIEWCUBE_PAD; let vc_y = VIEWCUBE_PAD; if pos.x >= vc_x && pos.x <= vc_x + VIEWCUBE_DRAW_PX && pos.y >= vc_y && pos.y <= vc_y + VIEWCUBE_DRAW_PX { return mouse::Interaction::None; } } } // Hover over a Model-tile divider → resize cursor cue. The // system cursor is intentionally shown here so the OS arrow gives // its own resize affordance; the draw step suppresses the custom // CAD crosshair while we're over a divider. if let Some(orient) = self.tile_edge_under(cursor, bounds) { return match orient { crate::scene::TileEdgeOrient::Vertical => { mouse::Interaction::ResizingHorizontally } crate::scene::TileEdgeOrient::Horizontal => { mouse::Interaction::ResizingVertically } }; } // Over the viewport (no divider, no viewcube): hide the system // cursor entirely. `Interaction::None` would let the stack fall // through to a sibling — `Hidden` is the explicit "no cursor" // signal that actually suppresses the OS arrow. if cursor.is_over(bounds) { mouse::Interaction::Hidden } else { mouse::Interaction::default() } } fn draw( &self, _state: &(), renderer: &iced::Renderer, _theme: &Theme, bounds: iced::Rectangle, cursor: mouse::Cursor, ) -> Vec { let mut frame = canvas::Frame::new(renderer, bounds.size()); // ── Tile dividers (Model-space tiled layout) ────────────────────── // Drawn first so all other overlays sit on top — the line is just // a visual cue for the resize-drag handles. if !self.tile_edges.is_empty() { const DIVIDER: Color = Color { r: 0.40, g: 0.45, b: 0.55, a: 0.85, }; for e in &self.tile_edges { let (a, b) = match e.orient { crate::scene::TileEdgeOrient::Vertical => { let x = e.coord * bounds.width; ( Point::new(x, e.span.0 * bounds.height), Point::new(x, e.span.1 * bounds.height), ) } crate::scene::TileEdgeOrient::Horizontal => { let y = e.coord * bounds.height; ( Point::new(e.span.0 * bounds.width, y), Point::new(e.span.1 * bounds.width, y), ) } }; let path = canvas::Path::new(|p| { p.move_to(a); p.line_to(b); }); frame.stroke( &path, canvas::Stroke { width: 2.0, style: canvas::Style::Solid(DIVIDER), line_cap: canvas::LineCap::Butt, ..Default::default() }, ); } } // ── Grid display ────────────────────────────────────────────────── // One entry per pane with its grid on. Each is clipped to its own // rectangle so lines never spill into neighbouring panes — and the clip // is intersected with the widget so a zoomed-in viewport whose rectangle // overflows the canvas never paints grid outside the 3-D view. for g in &self.grid { let gb = g.bounds; let cx0 = gb.x.max(0.0); let cy0 = gb.y.max(0.0); let cx1 = (gb.x + gb.width).min(bounds.width); let cy1 = (gb.y + gb.height).min(bounds.height); if cx1 <= cx0 || cy1 <= cy0 { continue; } let clip = iced::Rectangle { x: cx0, y: cy0, width: cx1 - cx0, height: cy1 - cy0, }; // Project with the full viewport rect `gb` so the grid stays aligned; // only the clip is clamped. frame.with_clip(clip, |f| { draw_grid(f, g.view_rot, g.eye, g.plane, gb, g.origin, g.axes) }); } if let (Some(a), Some(b)) = (self.selection.box_anchor, self.selection.box_current) { let (fill, stroke) = if self.selection.box_crossing { ( Color { r: 0.20, g: 0.72, b: 0.44, a: 0.12, }, Color { r: 0.20, g: 0.72, b: 0.44, a: 0.9, }, ) } else { ( Color { r: 0.20, g: 0.44, b: 0.72, a: 0.12, }, Color { r: 0.20, g: 0.44, b: 0.72, a: 0.9, }, ) }; let x0 = a.x.min(b.x); let y0 = a.y.min(b.y); let w = (a.x - b.x).abs(); let h = (a.y - b.y).abs(); let rect = canvas::Path::rectangle(Point::new(x0, y0), Size::new(w, h)); frame.fill(&rect, fill); frame.stroke( &rect, canvas::Stroke { width: 1.0, style: canvas::Style::Solid(stroke), ..Default::default() }, ); } if self.selection.poly_active && self.selection.poly_points.len() > 1 { let (fill, stroke) = if self.selection.poly_crossing { ( Color { r: 0.20, g: 0.72, b: 0.44, a: 0.12, }, Color { r: 0.20, g: 0.72, b: 0.44, a: 0.9, }, ) } else { ( Color { r: 0.20, g: 0.44, b: 0.72, a: 0.12, }, Color { r: 0.20, g: 0.44, b: 0.72, a: 0.9, }, ) }; if let Some(cur) = self.selection.last_move_pos { let start = self.selection.poly_points[0]; let fill_path = canvas::Path::new(|p| { p.move_to(start); for pt in &self.selection.poly_points[1..] { p.line_to(*pt); } p.line_to(cur); p.line_to(start); }); frame.fill(&fill_path, fill); } let path = canvas::Path::new(|p| { p.move_to(self.selection.poly_points[0]); for pt in &self.selection.poly_points[1..] { p.line_to(*pt); } }); frame.stroke( &path, canvas::Stroke { width: 1.0, style: canvas::Style::Solid(stroke), ..Default::default() }, ); if let Some(cur) = self.selection.last_move_pos { let start = self.selection.poly_points[0]; let last = *self.selection.poly_points.last().unwrap(); let preview = canvas::Path::new(|p| { p.move_to(last); p.line_to(cur); p.line_to(start); }); frame.stroke( &preview, canvas::Stroke { width: 1.0, style: canvas::Style::Solid(stroke), ..Default::default() }, ); } } // ── Grip markers ────────────────────────────────────────────────── for grip in &self.grips { let sp = grip.pos; let h = crate::scene::pick::grip::GRIP_HALF_PX; let path = match grip.shape { GripShape::Square => canvas::Path::rectangle( Point::new(sp.x - h, sp.y - h), Size::new(h * 2.0, h * 2.0), ), GripShape::Rectangle => { // Mid-segment stretch handle: small box, longer along // the segment direction so the affordance reads as // "stretch perpendicular to the segment". `dir` is a // world-XY direction vector; project it onto the // screen-X / screen-Y axes implied by the grip's // 2-D screen position to compute the in-plane angle. let half_long = h * 1.4; let half_short = h * 0.7; let (cos_t, sin_t) = match grip.dir { Some([dx, dy]) if (dx * dx + dy * dy) > 1e-12 => { let n = (dx * dx + dy * dy).sqrt(); // Screen Y is inverted vs world Y → flip sin. (dx / n, -dy / n) } _ => (1.0, 0.0), }; let ax = (cos_t * half_long, sin_t * half_long); let ay = (-sin_t * half_short, cos_t * half_short); canvas::Path::new(|b| { b.move_to(Point::new(sp.x + ax.0 + ay.0, sp.y + ax.1 + ay.1)); b.line_to(Point::new(sp.x + ax.0 - ay.0, sp.y + ax.1 - ay.1)); b.line_to(Point::new(sp.x - ax.0 - ay.0, sp.y - ax.1 - ay.1)); b.line_to(Point::new(sp.x - ax.0 + ay.0, sp.y - ax.1 + ay.1)); b.close(); }) } GripShape::Triangle => canvas::Path::new(|b| { b.move_to(Point::new(sp.x, sp.y - h)); b.line_to(Point::new(sp.x + h, sp.y + h)); b.line_to(Point::new(sp.x - h, sp.y + h)); b.close(); }), }; if grip.is_hot { // Hot grip: filled red marker let color = Color { r: 1.0, g: 0.15, b: 0.10, a: 1.0, }; frame.fill(&path, color); } else { // Normal grip: hollow blue marker let color = Color { r: 0.10, g: 0.45, b: 0.90, a: 1.0, }; let stroke = canvas::Stroke { width: 1.5, style: canvas::Style::Solid(color), ..Default::default() }; // Fill with semi-transparent background then stroke frame.fill( &path, Color { r: 0.10, g: 0.10, b: 0.20, a: 0.7, }, ); frame.stroke(&path, stroke); } } // ── Snap marker ─────────────────────────────────────────────────── if let Some((sp, snap_type)) = self.snap { let (r, g, b) = if snap_type == SnapType::ObjectPick { (0.95_f32, 0.50, 0.08) // orange object-snap marker } else { (1.0, 0.9, 0.1) // classic yellow OSNAP }; let marker = Color { r, g, b, a: 1.0 }; let stroke = canvas::Stroke { width: if snap_type == SnapType::ObjectPick { 2.0 } else { 1.5 }, style: canvas::Style::Solid(marker), ..Default::default() }; match snap_type { SnapType::ObjectPick => { // Target box + center dot (object-acquisition glyph). let h = 7.0_f32; let rect = canvas::Path::rectangle( Point::new(sp.x - h, sp.y - h), Size::new(h * 2.0, h * 2.0), ); frame.stroke(&rect, stroke.clone()); let r = 3.0_f32; frame.fill( &canvas::Path::circle(sp, r), Color { r: 0.95, g: 0.50, b: 0.08, a: 0.85, }, ); } SnapType::Endpoint => { let h = 5.0_f32; let rect = canvas::Path::rectangle( Point::new(sp.x - h, sp.y - h), Size::new(h * 2.0, h * 2.0), ); frame.stroke(&rect, stroke); } SnapType::Midpoint => { let r = 6.0_f32; let path = canvas::Path::new(|b| { b.move_to(Point::new(sp.x, sp.y - r)); b.line_to(Point::new(sp.x + r * 0.866, sp.y + r * 0.5)); b.line_to(Point::new(sp.x - r * 0.866, sp.y + r * 0.5)); b.close(); }); frame.stroke(&path, stroke); } SnapType::Center => { let r = 5.5_f32; let path = canvas::Path::circle(sp, r); frame.stroke(&path, stroke); } SnapType::Node => { // Circle with an inscribed X. let r = 5.5_f32; let cpath = canvas::Path::circle(sp, r); frame.stroke(&cpath, stroke.clone()); let d = r * std::f32::consts::FRAC_1_SQRT_2; let x1 = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - d, sp.y - d)); b.line_to(Point::new(sp.x + d, sp.y + d)); }); let x2 = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - d, sp.y + d)); b.line_to(Point::new(sp.x + d, sp.y - d)); }); frame.stroke(&x1, stroke.clone()); frame.stroke(&x2, stroke); } SnapType::Quadrant => { let r = 6.0_f32; let path = canvas::Path::new(|b| { b.move_to(Point::new(sp.x, sp.y - r)); b.line_to(Point::new(sp.x + r, sp.y)); b.line_to(Point::new(sp.x, sp.y + r)); b.line_to(Point::new(sp.x - r, sp.y)); b.close(); }); frame.stroke(&path, stroke); } SnapType::Intersection => { let r = 5.0_f32; let p1 = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - r, sp.y - r)); b.line_to(Point::new(sp.x + r, sp.y + r)); }); let p2 = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - r, sp.y + r)); b.line_to(Point::new(sp.x + r, sp.y - r)); }); frame.stroke(&p1, stroke.clone()); frame.stroke(&p2, stroke); } SnapType::ApparentIntersection => { // X like Intersection, framed by a small square so the // two are visually distinguishable. let r = 5.0_f32; let rect = canvas::Path::rectangle( Point::new(sp.x - r, sp.y - r), Size::new(r * 2.0, r * 2.0), ); frame.stroke(&rect, stroke.clone()); let xr = r - 1.5; let p1 = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - xr, sp.y - xr)); b.line_to(Point::new(sp.x + xr, sp.y + xr)); }); let p2 = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - xr, sp.y + xr)); b.line_to(Point::new(sp.x + xr, sp.y - xr)); }); frame.stroke(&p1, stroke.clone()); frame.stroke(&p2, stroke); } SnapType::Insertion => { // Two overlapping rectangles (a small "tag" glyph). let r = 5.0_f32; let inner = canvas::Path::rectangle( Point::new(sp.x - r * 0.5, sp.y - r), Size::new(r, r * 2.0), ); let outer = canvas::Path::rectangle( Point::new(sp.x - r, sp.y - r * 0.5), Size::new(r * 2.0, r), ); frame.stroke(&outer, stroke.clone()); frame.stroke(&inner, stroke); } SnapType::Perpendicular => { // Right-angle hook in the lower-left quadrant. let r = 6.0_f32; let p = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - r, sp.y - r)); b.line_to(Point::new(sp.x - r, sp.y + r)); b.line_to(Point::new(sp.x + r, sp.y + r)); }); let foot = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - r, sp.y)); b.line_to(Point::new(sp.x, sp.y)); b.line_to(Point::new(sp.x, sp.y + r)); }); frame.stroke(&p, stroke.clone()); frame.stroke(&foot, stroke); } SnapType::Tangent => { // Circle with a tangent bar across the top. let r = 5.5_f32; let c = canvas::Path::circle(sp, r); frame.stroke(&c, stroke.clone()); let bar = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - r, sp.y - r)); b.line_to(Point::new(sp.x + r, sp.y - r)); }); frame.stroke(&bar, stroke); } SnapType::Nearest => { // Bowtie / hourglass — two opposed triangles meeting at sp. let r = 5.5_f32; let path = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - r, sp.y - r)); b.line_to(Point::new(sp.x + r, sp.y - r)); b.line_to(Point::new(sp.x - r, sp.y + r)); b.line_to(Point::new(sp.x + r, sp.y + r)); b.close(); }); frame.stroke(&path, stroke); } SnapType::Extension => { // Three dots strung along a tracked direction. let r = 1.4_f32; for k in [-7.0_f32, 0.0, 7.0] { let dot = canvas::Path::circle(Point::new(sp.x + k, sp.y), r); frame.fill(&dot, marker); } } SnapType::Parallel => { // Two short parallel diagonal bars. let r = 6.0_f32; let off = 3.0_f32; let b1 = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - r - off, sp.y + r)); b.line_to(Point::new(sp.x + r - off, sp.y - r)); }); let b2 = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - r + off, sp.y + r)); b.line_to(Point::new(sp.x + r + off, sp.y - r)); }); frame.stroke(&b1, stroke.clone()); frame.stroke(&b2, stroke); } SnapType::Grid => { let arm = 4.0_f32; let h = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - arm, sp.y)); b.line_to(Point::new(sp.x + arm, sp.y)); }); let v = canvas::Path::new(|b| { b.move_to(Point::new(sp.x, sp.y - arm)); b.line_to(Point::new(sp.x, sp.y + arm)); }); frame.stroke(&h, stroke.clone()); frame.stroke(&v, stroke); } } } // ── CAD crosshair cursor ────────────────────────────────────────────── let over_viewcube = self.show_viewcube && { use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD}; cursor.position_in(bounds).map_or(false, |pos| { let vc_x = bounds.width - VIEWCUBE_DRAW_PX - VIEWCUBE_PAD; let vc_y = VIEWCUBE_PAD; pos.x >= vc_x && pos.x <= vc_x + VIEWCUBE_DRAW_PX && pos.y >= vc_y && pos.y <= vc_y + VIEWCUBE_DRAW_PX }) }; // Over a Model-tile divider the OS cursor switches to a resize // arrow (see `mouse_interaction`); drawing the CAD crosshair on // top of it would double up the visual feedback. let over_divider = self.tile_edge_under(cursor, bounds).is_some(); if !over_viewcube && !over_divider { if let Some(cp) = self.selection.last_move_pos { let color = Color { r: 0.85, g: 0.85, b: 0.85, a: 0.90, }; let stroke = canvas::Stroke { width: 1.0, style: canvas::Style::Solid(color), ..Default::default() }; let sq = CROSSHAIR_SQ; // square half-size → 15×15 let arm = CROSSHAIR_ARM; // crosshair arm length from center // Horizontal arms (start at square edge, end at arm length) let h_left = canvas::Path::new(|b| { b.move_to(Point::new(cp.x - sq, cp.y)); b.line_to(Point::new(cp.x - arm, cp.y)); }); let h_right = canvas::Path::new(|b| { b.move_to(Point::new(cp.x + sq, cp.y)); b.line_to(Point::new(cp.x + arm, cp.y)); }); // Vertical arms let v_top = canvas::Path::new(|b| { b.move_to(Point::new(cp.x, cp.y - sq)); b.line_to(Point::new(cp.x, cp.y - arm)); }); let v_bot = canvas::Path::new(|b| { b.move_to(Point::new(cp.x, cp.y + sq)); b.line_to(Point::new(cp.x, cp.y + arm)); }); // Center square let square = canvas::Path::rectangle( Point::new(cp.x - sq, cp.y - sq), Size::new(sq * 2.0, sq * 2.0), ); frame.stroke(&h_left, stroke.clone()); frame.stroke(&h_right, stroke.clone()); frame.stroke(&v_top, stroke.clone()); frame.stroke(&v_bot, stroke.clone()); frame.stroke(&square, stroke); } } // end !over_viewcube // ── UCS icon ────────────────────────────────────────────────────── if let Some(ref ucs) = self.ucs_icon { draw_ucs_icon(&mut frame, ucs.view_proj, ucs.bounds, ucs.axes); } // ── Object Snap Tracking lines ──────────────────────────────────── for ost in &self.ost_points { let tp = ost.screen; let cx = self.cursor_screen.x; let cy = self.cursor_screen.y; let track_color = Color { r: 0.15, g: 0.85, b: 0.95, a: 0.7, }; let dash_stroke = canvas::Stroke::default() .with_color(track_color) .with_width(1.0); // Draw horizontal line from tracking point to cursor. if (cy - tp.y).abs() < 8.0 { let path = canvas::Path::line(tp, Point { x: cx, y: tp.y }); frame.stroke(&path, dash_stroke.clone()); } // Draw vertical line. if (cx - tp.x).abs() < 8.0 { let path = canvas::Path::line(tp, Point { x: tp.x, y: cy }); frame.stroke(&path, dash_stroke.clone()); } // Small cross at the tracking point. let sz = 5.0_f32; let h = canvas::Path::line( Point { x: tp.x - sz, y: tp.y, }, Point { x: tp.x + sz, y: tp.y, }, ); let v = canvas::Path::line( Point { x: tp.x, y: tp.y - sz, }, Point { x: tp.x, y: tp.y + sz, }, ); frame.stroke(&h, dash_stroke.clone()); frame.stroke(&v, dash_stroke); } vec![frame.into_geometry()] } } // ── Grid line drawing ───────────────────────────────────────────────────── /// Minimum pixel gap between adjacent grid lines before stepping up to next spacing. const MIN_GRID_PX: f32 = 20.0; fn draw_grid( frame: &mut canvas::Frame, view_rot: Mat4, eye: glam::DVec3, plane: GridPlane, bounds: iced::Rectangle, grid_origin: glam::DVec3, grid_axes: (Vec3, Vec3, Vec3), ) { // World → canvas screen via relative-to-eye: subtract the f64 eye first so // grid points near the camera stay precise at UTM-scale absolute coords. let w2s = |world: glam::DVec3| -> Point { let rel = (world - eye).as_vec3(); let ndc = view_rot.project_point3(rel); Point::new( bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width, bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height, ) }; // Plane-tangent axes: axis1 and axis2 span the grid plane, taken from the // active UCS basis so the grid aligns to the user's coordinate system. let (gx, gy, gz) = grid_axes; let (axis1, axis2) = match plane { GridPlane::Xz => (gx, gz), GridPlane::Xy => (gx, gy), GridPlane::Yz => (gy, gz), }; // Adaptive spacing: measure pixels per 1-unit step along each axis, // then find the smallest power-of-5 multiple that gives ≥ MIN_GRID_PX. let o = w2s(grid_origin); let a1s = w2s(grid_origin + axis1.as_dvec3()); let a2s = w2s(grid_origin + axis2.as_dvec3()); let px1 = ((a1s.x - o.x).powi(2) + (a1s.y - o.y).powi(2)).sqrt(); let px2 = ((a2s.x - o.x).powi(2) + (a2s.y - o.y).powi(2)).sqrt(); let px_per_unit = px1.max(px2); if px_per_unit < 1e-6 { return; } let mut s = 1.0_f32; while s * px_per_unit < MIN_GRID_PX { s *= 5.0; if s > 1e9 { return; } } // Visible world extent: unproject screen corners (mid-depth approximation) // and project them onto the grid axes. let inv = view_rot.inverse(); let unproject = |sx: f32, sy: f32| -> glam::DVec3 { let ndc_x = (sx / bounds.width) * 2.0 - 1.0; let ndc_y = 1.0 - (sy / bounds.height) * 2.0; // Unproject in eye-relative space, then add the f64 eye back. eye + inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.5)).as_dvec3() }; let corners = [ unproject(0.0, 0.0), unproject(bounds.width, 0.0), unproject(0.0, bounds.height), unproject(bounds.width, bounds.height), ]; let range = |ax: Vec3| -> (f32, f32) { let vals: Vec = corners .iter() .map(|p| (*p - grid_origin).as_vec3().dot(ax)) .collect(); ( vals.iter().cloned().fold(f32::INFINITY, f32::min), vals.iter().cloned().fold(f32::NEG_INFINITY, f32::max), ) }; let (min1, max1) = range(axis1); let (min2, max2) = range(axis2); let n1_s = (min1 / s).floor() as i32 - 1; let n1_e = (max1 / s).ceil() as i32 + 1; let n2_s = (min2 / s).floor() as i32 - 1; let n2_e = (max2 / s).ceil() as i32 + 1; if (n1_e - n1_s) > 500 || (n2_e - n2_s) > 500 { return; } let gc = Color { r: 0.28, g: 0.28, b: 0.28, a: 0.7, }; let st = canvas::Stroke { width: 0.5, style: canvas::Style::Solid(gc), ..Default::default() }; // Lines parallel to axis2 (varying axis1 position) for i in n1_s..=n1_e { let v = i as f32 * s; let p0 = w2s(grid_origin + (axis1 * v + axis2 * (min2 - s)).as_dvec3()); let p1 = w2s(grid_origin + (axis1 * v + axis2 * (max2 + s)).as_dvec3()); frame.stroke( &canvas::Path::new(|b| { b.move_to(p0); b.line_to(p1); }), st.clone(), ); } // Lines parallel to axis1 (varying axis2 position) for i in n2_s..=n2_e { let v = i as f32 * s; let p0 = w2s(grid_origin + (axis2 * v + axis1 * (min1 - s)).as_dvec3()); let p1 = w2s(grid_origin + (axis2 * v + axis1 * (max1 + s)).as_dvec3()); frame.stroke( &canvas::Path::new(|b| { b.move_to(p0); b.line_to(p1); }), st.clone(), ); } // Coloured axes drawn on top of the grid lines, along the same UCS basis. let extent = (min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs()) + s) * 1.5; draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, grid_axes); } // ── Coloured UCS axes ────────────────────────────────────────────────────── fn draw_axes( frame: &mut canvas::Frame, view_rot: Mat4, eye: glam::DVec3, bounds: iced::Rectangle, extent: f32, origin: glam::DVec3, axes: (Vec3, Vec3, Vec3), ) { let w2s = |world: glam::DVec3| -> Point { let ndc = view_rot.project_point3((world - eye).as_vec3()); Point::new( bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width, bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height, ) }; let e = extent; let (ax, ay, az) = axes; let axis_stroke = |r: f32, g: f32, b: f32| canvas::Stroke { width: 1.5, style: canvas::Style::Solid(Color { r, g, b, a: 0.85 }), ..Default::default() }; // Axes run through the UCS origin along the UCS axis directions. let mut line = |dir: Vec3, r: f32, g: f32, b: f32| { frame.stroke( &canvas::Path::new(|p| { p.move_to(w2s(origin - (dir * e).as_dvec3())); p.line_to(w2s(origin + (dir * e).as_dvec3())); }), axis_stroke(r, g, b), ); }; line(ax, 0.90, 0.20, 0.20); // X — red line(ay, 0.20, 0.85, 0.20); // Y — green line(az, 0.20, 0.40, 0.90); // Z — blue } // ── UCS icon ────────────────────────────────────────────────────────────── // // Draws a small X/Y/Z axis tripod in the bottom-left corner of the viewport. // The axis directions are projected from world space so the icon rotates with // the camera. Axis lengths are proportional (foreshortening preserved), depth // ordering is computed from NDC Z, and axes going away from the viewer are // drawn as outlined circles with reduced opacity. const UCS_ICON_MARGIN: f32 = 50.0; const UCS_ICON_LEN: f32 = 38.0; // longest axis arm in screen pixels const UCS_ICON_TIP: f32 = 7.0; // arrowhead size in pixels fn draw_ucs_icon( frame: &mut canvas::Frame, vp: Mat4, bounds: iced::Rectangle, axes: (Vec3, Vec3, Vec3), ) { if bounds.width < 10.0 || bounds.height < 10.0 { return; } // Project to NDC (including depth) then to screen pixels. let w2ndc = |world: Vec3| -> Option { let ndc = vp.project_point3(world); if !ndc.x.is_finite() || !ndc.y.is_finite() || !ndc.z.is_finite() { return None; } Some(ndc) }; let ndc2s = |ndc: Vec3| -> Point { Point::new( bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width, bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height, ) }; // Project the UCS axis directions (not fixed world axes) so the tripod // rotates to the active UCS. Directions are translation-invariant, so a // common origin is fine. let (ax, ay, az) = axes; let Some(org) = w2ndc(Vec3::ZERO) else { return }; let Some(xn) = w2ndc(ax) else { return }; let Some(yn) = w2ndc(ay) else { return }; let Some(zn) = w2ndc(az) else { return }; let org_s = ndc2s(org); let icon_origin = Point::new( bounds.x + UCS_ICON_MARGIN, bounds.y + (bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN), ); // Raw screen-space displacement for each axis tip. let raw = |ndc_tip: Vec3| -> (f32, f32, f32) { let s = ndc2s(ndc_tip); let dx = s.x - org_s.x; let dy = s.y - org_s.y; (dx, dy, (dx * dx + dy * dy).sqrt()) }; let (xdx, xdy, xlen) = raw(xn); let (ydx, ydy, ylen) = raw(yn); let (zdx, zdy, zlen) = raw(zn); // Scale so the longest projected axis fills UCS_ICON_LEN; shorter axes // stay proportionally shorter (this IS the foreshortening effect). let max_len = xlen.max(ylen).max(zlen).max(1e-4); let sc = UCS_ICON_LEN / max_len; // depth > 0 → tip is farther from viewer than origin (axis going into screen). // depth < 0 → tip is closer (axis coming toward viewer). struct AxisInfo { dx: f32, dy: f32, sc_len: f32, depth: f32, r: f32, g: f32, b: f32, label: &'static str, } let mut axes = [ AxisInfo { dx: xdx * sc, dy: xdy * sc, sc_len: xlen * sc, depth: xn.z - org.z, r: 0.90, g: 0.22, b: 0.22, label: "X", }, AxisInfo { dx: ydx * sc, dy: ydy * sc, sc_len: ylen * sc, depth: yn.z - org.z, r: 0.22, g: 0.85, b: 0.22, label: "Y", }, AxisInfo { dx: zdx * sc, dy: zdy * sc, sc_len: zlen * sc, depth: zn.z - org.z, r: 0.22, g: 0.45, b: 0.90, label: "Z", }, ]; // Back-to-front: draw axis farthest from viewer first. axes.sort_by(|a, b| { b.depth .partial_cmp(&a.depth) .unwrap_or(std::cmp::Ordering::Equal) }); for ax in &axes { let col = Color { r: ax.r, g: ax.g, b: ax.b, a: 1.0, }; let tip = Point::new(icon_origin.x + ax.dx, icon_origin.y + ax.dy); // Shaft if ax.sc_len > 1.0 { let path = canvas::Path::new(|p| { p.move_to(icon_origin); p.line_to(tip); }); frame.stroke( &path, canvas::Stroke { width: 2.0, style: canvas::Style::Solid(col), line_cap: canvas::LineCap::Butt, ..Default::default() }, ); } // Filled arrowhead at tip. if ax.sc_len > 3.0 { let (nx, ny) = if ax.sc_len > 1e-3 { (ax.dx / ax.sc_len, ax.dy / ax.sc_len) } else { (1.0, 0.0) }; let px = -ny; let py = nx; let tl = Point::new( tip.x - nx * UCS_ICON_TIP + px * (UCS_ICON_TIP * 0.45), tip.y - ny * UCS_ICON_TIP + py * (UCS_ICON_TIP * 0.45), ); let tr = Point::new( tip.x - nx * UCS_ICON_TIP - px * (UCS_ICON_TIP * 0.45), tip.y - ny * UCS_ICON_TIP - py * (UCS_ICON_TIP * 0.45), ); let arrow = canvas::Path::new(|p| { p.move_to(tip); p.line_to(tl); p.line_to(tr); p.close(); }); frame.fill(&arrow, col); } // Axis label (X / Y / Z) beyond the tip. if ax.sc_len > 4.0 { let (nx, ny) = if ax.sc_len > 1e-3 { (ax.dx / ax.sc_len, ax.dy / ax.sc_len) } else { (1.0, 0.0) }; frame.fill_text(canvas::Text { content: ax.label.to_string(), // Offset beyond tip along the axis direction; subtract ~half glyph // size to visually center the single character on the axis line. position: Point::new(tip.x + nx * 8.0 - 3.5, tip.y + ny * 8.0 - 5.0), color: col, size: iced::Pixels(10.0), shaping: iced::advanced::text::Shaping::Advanced, ..Default::default() }); } } // Origin dot. let circle = canvas::Path::circle(icon_origin, 3.5); frame.fill( &circle, Color { r: 0.9, g: 0.9, b: 0.9, a: 0.95, }, ); } // ── Dynamic Input overlay ───────────────────────────────────────────────── use crate::command::{DynGuide, DynRole}; const DYN_OFFSET_X: f32 = 14.0; const DYN_PAD: f32 = 4.0; const DYN_GAP: f32 = 6.0; const DYN_FONT: f32 = 11.0; const DYN_CHAR_W: f32 = DYN_FONT * 0.62; const DYN_BOX_H: f32 = DYN_FONT + DYN_PAD * 2.0; /// One value box in the dynamic-input overlay. Its `role` drives both the /// label and where the box is placed relative to the step's guide geometry. #[derive(Clone)] pub struct DynBox { pub label: String, pub value: String, /// TAB-focused box — keystrokes edit this one. pub active: bool, /// User has typed a value (the box no longer tracks the cursor). pub locked: bool, pub role: DynRole, } pub fn dynamic_input_overlay<'a>( cursor_screen: Point, base_screen: Option, ref_screen: Option, guide: DynGuide, boxes: Vec, prompt: String, ) -> Element<'a, Message> { canvas(DynInputCanvas { cursor_screen, base_screen, ref_screen, guide, boxes, prompt, }) .width(Length::Fill) .height(Length::Fill) .into() } struct DynInputCanvas { cursor_screen: Point, /// Step anchor in viewport pixels (projected `dyn_anchor`). Guided layouts /// (polar / radius / axis-delta) need it; `None` falls back to a cursor row. base_screen: Option, /// Far end of the reference line (projected `dyn_ref`) — for `Perp`. ref_screen: Option, guide: DynGuide, boxes: Vec, /// The active command's current prompt, drawn just above the boxes. prompt: String, } impl DynInputCanvas { fn dotted() -> canvas::Stroke<'static> { canvas::Stroke { width: 1.0, style: canvas::Style::Solid(Color { r: 0.55, g: 0.55, b: 0.58, a: 0.9 }), line_dash: canvas::LineDash { segments: &[2.0, 3.0], offset: 0 }, ..Default::default() } } fn box_content(b: &DynBox) -> String { match b.role { DynRole::Angle => format!("{}\u{00B0}", b.value), _ if b.label.is_empty() => b.value.clone(), _ => format!("{}{}", b.label, b.value), } } /// Draw a value box centred at `center`, clamped inside `bounds`. fn draw_box(frame: &mut canvas::Frame, b: &DynBox, center: Point, bounds: iced::Rectangle) { let content = Self::box_content(b); let w = (content.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0; let x = (center.x - w * 0.5).clamp(0.0, (bounds.width - w).max(0.0)); let y = (center.y - DYN_BOX_H * 0.5).clamp(0.0, (bounds.height - DYN_BOX_H).max(0.0)); let rect = canvas::Path::rectangle(Point { x, y }, Size { width: w, height: DYN_BOX_H }); let (fill, border) = Self::box_colors(b); frame.fill(&rect, fill); frame.stroke( &rect, canvas::Stroke::default() .with_color(border) .with_width(if b.active { 1.6 } else { 1.0 }), ); frame.fill_text(canvas::Text { content, position: Point { x: x + DYN_PAD, y: y + DYN_PAD }, color: Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 }, size: iced::Pixels(DYN_FONT), // Force Advanced shaping: the default `Auto` uses Basic shaping for // ASCII-only strings, which the web (wgpu/webgl) backend fails to // render — so all-digit value boxes came up blank while the angle // box (containing the non-ASCII `°`) rendered. (#117) shaping: iced::advanced::text::Shaping::Advanced, ..Default::default() }); } fn box_colors(b: &DynBox) -> (Color, Color) { if b.active { ( Color { r: 0.12, g: 0.18, b: 0.30, a: 0.95 }, Color { r: 0.45, g: 0.70, b: 1.0, a: 1.0 }, ) } else if b.locked { ( Color { r: 0.05, g: 0.05, b: 0.12, a: 0.9 }, Color { r: 0.95, g: 0.75, b: 0.30, a: 0.9 }, ) } else { ( Color { r: 0.05, g: 0.05, b: 0.12, a: 0.9 }, Color { r: 0.35, g: 0.55, b: 0.90, a: 0.9 }, ) } } /// Prompt pill at `pos`. fn draw_prompt(&self, frame: &mut canvas::Frame, pos: Point) { if self.prompt.is_empty() { return; } let pw = (self.prompt.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0; let rect = canvas::Path::rectangle(pos, Size { width: pw, height: DYN_BOX_H }); frame.fill(&rect, Color { r: 0.10, g: 0.10, b: 0.12, a: 1.0 }); frame.stroke( &rect, canvas::Stroke::default() .with_color(Color { r: 0.35, g: 0.55, b: 0.90, a: 0.9 }) .with_width(1.0), ); frame.fill_text(canvas::Text { content: self.prompt.clone(), position: Point { x: pos.x + DYN_PAD, y: pos.y + DYN_PAD }, color: Color { r: 0.70, g: 0.85, b: 0.70, a: 1.0 }, size: iced::Pixels(DYN_FONT), shaping: iced::advanced::text::Shaping::Advanced, ..Default::default() }); } /// Guided layout: draw the guide geometry anchored at `base`, then place /// each box according to its role. fn draw_guided(&self, frame: &mut canvas::Frame, bounds: iced::Rectangle, base: Point) { let cursor = self.cursor_screen; let (vx, vy) = (cursor.x - base.x, cursor.y - base.y); let len = (vx * vx + vy * vy).sqrt().max(1.0); let (dx, dy) = (vx / len, vy / len); // Perpendicular pointing to the lower half so labels sit under the line. let (mut nx, mut ny) = (-dy, dx); if ny < 0.0 { nx = -nx; ny = -ny; } // Polar arc reference direction: a supplied reference point (e.g. the // ROTATE reference), else the +X axis. The arc sweeps the short way // from that reference to the cursor. let a_cur = dy.atan2(dx); let a_ref = self .ref_screen .map(|r| (r.y - base.y).atan2(r.x - base.x)) .unwrap_or(0.0); let mut sweep = a_cur - a_ref; while sweep > std::f32::consts::PI { sweep -= std::f32::consts::TAU; } while sweep <= -std::f32::consts::PI { sweep += std::f32::consts::TAU; } let corner = Point { x: cursor.x, y: base.y }; // axis-delta elbow // Perp / PerpDim: perpendicular direction to the reference line, the // measured endpoint along it (`end`), and an offset dimension segment // (`off_base`→`off_end`) drawn clear of the edge for PerpDim. let perp_info = self.ref_screen.map(|r| { let (ax, ay) = (r.x - base.x, r.y - base.y); let al = (ax * ax + ay * ay).sqrt().max(1.0); let (ux, uy) = (ax / al, ay / al); // axis unit (base → ref) let (px, py) = (-uy, ux); // perpendicular unit let signed = (cursor.x - base.x) * px + (cursor.y - base.y) * py; let end = Point { x: base.x + px * signed, y: base.y + py * signed }; const OFF: f32 = 16.0; // dimension offset, away from the reference let off_base = Point { x: base.x - ux * OFF, y: base.y - uy * OFF }; let off_end = Point { x: end.x - ux * OFF, y: end.y - uy * OFF }; (end, off_base, off_end) }); // ── Guide geometry ── match self.guide { DynGuide::Polar => { // Reference line along `a_ref` (the +X axis, or the supplied // reference direction), then the arc from it to the cursor. let href = canvas::Path::new(|p| { p.move_to(base); p.line_to(Point { x: base.x + a_ref.cos() * len, y: base.y + a_ref.sin() * len, }); }); frame.stroke(&href, Self::dotted()); let arc = canvas::Path::new(|p| { let steps = 48; for k in 0..=steps { let a = a_ref + sweep * (k as f32 / steps as f32); let pt = Point { x: base.x + a.cos() * len, y: base.y + a.sin() * len, }; if k == 0 { p.move_to(pt); } else { p.line_to(pt); } } }); frame.stroke(&arc, Self::dotted()); } DynGuide::Radius => { let line = canvas::Path::new(|p| { p.move_to(base); p.line_to(cursor); }); frame.stroke(&line, Self::dotted()); } DynGuide::Perp => { if let Some((end, _, _)) = perp_info { // The measured semi-axis: anchor → perpendicular endpoint. let line = canvas::Path::new(|p| { p.move_to(base); p.line_to(end); }); frame.stroke(&line, Self::dotted()); } } DynGuide::PerpDim => { if let Some((end, ob, oe)) = perp_info { // Dimension segment offset off the edge, with extension // lines back to the two measured corners. let dim = canvas::Path::new(|p| { p.move_to(ob); p.line_to(oe); }); frame.stroke(&dim, Self::dotted()); let ext = canvas::Path::new(|p| { p.move_to(base); p.line_to(ob); p.move_to(end); p.line_to(oe); }); frame.stroke(&ext, Self::dotted()); } } DynGuide::AxisDelta | DynGuide::RectSides => { // Dotted legs from the anchor along its axes to the cursor. let legs = canvas::Path::new(|p| { p.move_to(base); p.line_to(corner); p.line_to(cursor); }); frame.stroke(&legs, Self::dotted()); if self.guide == DynGuide::RectSides { // Close the rectangle so both side pairs read as a box. let rest = canvas::Path::new(|p| { p.move_to(base); p.line_to(Point { x: base.x, y: cursor.y }); p.line_to(cursor); }); frame.stroke(&rest, Self::dotted()); } } DynGuide::None => {} } // ── Box placement by role ── for b in &self.boxes { let center = match b.role { DynRole::Angle => { let a_mid = a_ref + sweep * 0.5; // Pull the box back along the ray and lift it to the side // opposite the distance box. A near-zero sweep collapses the // mid-angle direction onto the cursor ray, so placing the box // at full `len` would plant it on the cursor / snap point and // hide it. (#124) let r = (len - DYN_BOX_H * 2.0).max(len * 0.5); Point { x: base.x + a_mid.cos() * r - nx * 18.0, y: base.y + a_mid.sin() * r - ny * 18.0, } } DynRole::X | DynRole::Width => Point { x: (base.x + cursor.x) * 0.5, y: base.y + 14.0, }, DynRole::Y | DynRole::Height => Point { x: corner.x + 18.0, y: (base.y + cursor.y) * 0.5, }, // Perpendicular measure: on the measured segment / dim line. _ if matches!(self.guide, DynGuide::Perp | DynGuide::PerpDim) && perp_info.is_some() => { let (end, ob, oe) = perp_info.unwrap(); if self.guide == DynGuide::PerpDim { Point { x: (ob.x + oe.x) * 0.5 + 8.0, y: (ob.y + oe.y) * 0.5 } } else { Point { x: (base.x + end.x) * 0.5 + 8.0, y: (base.y + end.y) * 0.5 } } } // Distance / Radius / Diameter and anything else ride the line. _ => Point { x: base.x + dx * len * 0.5 + nx * 16.0, y: base.y + dy * len * 0.5 + ny * 16.0, }, }; Self::draw_box(frame, b, center, bounds); } } /// Fallback row layout near the cursor (no anchor / `None` guide). fn draw_row(&self, frame: &mut canvas::Frame, bounds: iced::Rectangle) { let texts: Vec = self .boxes .iter() .map(|b| { if b.label.is_empty() { b.value.clone() } else { format!("{}:{}", b.label, b.value) } }) .collect(); let widths: Vec = texts .iter() .map(|t| (t.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0) .collect(); let total_w: f32 = widths.iter().sum::() + DYN_GAP * (self.boxes.len() as f32 - 1.0); // Offset the block off the crosshair by the same gap horizontally and // vertically; the prompt sits a gap below the horizontal axis and the // value boxes a further gap below the prompt. let pad = DYN_OFFSET_X; let has_prompt = !self.prompt.is_empty(); let prompt_w = (self.prompt.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0; let block_w = total_w.max(if has_prompt { prompt_w } else { 0.0 }); let mut bx = self.cursor_screen.x + pad; let mut py = self.cursor_screen.y + pad; let mut by = if has_prompt { py + DYN_BOX_H + pad } else { py }; if bx + block_w > bounds.width { bx = (self.cursor_screen.x - block_w - 4.0).max(0.0); } if by + DYN_BOX_H > bounds.height { // Flip the block above the cursor, keeping the same gaps. by = (self.cursor_screen.y - pad - DYN_BOX_H).max(0.0); py = (by - pad - DYN_BOX_H).max(0.0); } if has_prompt { self.draw_prompt(frame, Point { x: bx, y: py }); } let mut x = bx; for (i, b) in self.boxes.iter().enumerate() { let w = widths[i]; let rect = canvas::Path::rectangle(Point { x, y: by }, Size { width: w, height: DYN_BOX_H }); let (fill, border) = Self::box_colors(b); frame.fill(&rect, fill); frame.stroke( &rect, canvas::Stroke::default() .with_color(border) .with_width(if b.active { 1.6 } else { 1.0 }), ); frame.fill_text(canvas::Text { content: texts[i].clone(), position: Point { x: x + DYN_PAD, y: by + DYN_PAD }, color: Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 }, size: iced::Pixels(DYN_FONT), shaping: iced::advanced::text::Shaping::Advanced, ..Default::default() }); x += w + DYN_GAP; } } } impl canvas::Program for DynInputCanvas { type State = (); fn mouse_interaction( &self, _state: &(), _bounds: iced::Rectangle, _cursor: mouse::Cursor, ) -> mouse::Interaction { mouse::Interaction::None } fn draw( &self, _state: &(), renderer: &iced::Renderer, _theme: &Theme, bounds: iced::Rectangle, _cursor: mouse::Cursor, ) -> Vec { let mut frame = canvas::Frame::new(renderer, bounds.size()); // No boxes — just the prompt pill near the cursor. if self.boxes.is_empty() { if !self.prompt.is_empty() { let pw = (self.prompt.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0; let mut px = self.cursor_screen.x + DYN_OFFSET_X; let mut py = self.cursor_screen.y + DYN_OFFSET_X; if px + pw > bounds.width { px = (self.cursor_screen.x - pw - 4.0).max(0.0); } if py + DYN_BOX_H > bounds.height { py = (self.cursor_screen.y - DYN_BOX_H - 4.0).max(0.0); } self.draw_prompt(&mut frame, Point { x: px, y: py }); } return vec![frame.into_geometry()]; } // Guided layouts need the anchor; without it fall back to a cursor row. match (self.guide, self.base_screen) { (DynGuide::None, _) | (_, None) => self.draw_row(&mut frame, bounds), (_, Some(base)) => self.draw_guided(&mut frame, bounds, base), } vec![frame.into_geometry()] } }