use super::super::Message; use iced::widget::{ button, column, container, mouse_area, pick_list, row, stack, text, text_input, Space, }; use iced::{Background, Border, Color, Element, Fill, Theme}; pub(super) 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(in crate::app) const MTEXT_TEXT_ID: &str = "mtext_editor_text"; /// Widget id for the in-place TEXT editor's input (focused when it opens). pub(in crate::app) 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. pub(super) fn text_inline_overlay( ed: &super::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. /// 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, f32)>, /// Per-visible-character boxes (world frame) for click-to-select. boxes: Vec, /// Current selection as a visible-char range. sel: Option<(usize, usize)>, /// Caret position as a visible-char offset. caret: usize, /// Whether the caret is in its visible blink phase. caret_on: bool, /// World-space min corner (bbox) and pixels-per-world-unit scale. minx: f32, miny: f32, scale: f32, content_h: f32, } impl MTextPreview { /// Visible-char offset (0..=N) nearest the cursor point (bounds-local px). fn offset_at(&self, p: iced::Point) -> usize { if self.boxes.is_empty() { return 0; } let wx = self.minx + (p.x - MTEXT_PREVIEW_PAD) / self.scale; let wy = self.miny + (self.content_h - p.y - MTEXT_PREVIEW_PAD) / self.scale; let mut best = 0usize; let mut best_d = f32::MAX; for b in &self.boxes { let dx = if wx < b.xmin { b.xmin - wx } else if wx > b.xmax { wx - b.xmax } else { 0.0 }; let dy = if wy < b.ymin { b.ymin - wy } else if wy > b.ymax { wy - b.ymax } else { 0.0 }; let d = dy * 1000.0 + dx; // prefer the correct line first if d < best_d { best_d = d; best = b.vis; // After the glyph centre → caret sits after this char. if wx > (b.xmin + b.xmax) * 0.5 { best = b.vis + 1; } } } best } } #[derive(Default)] struct MTextPreviewState { dragging: bool, } impl iced::widget::canvas::Program for MTextPreview { type State = MTextPreviewState; fn update( &self, state: &mut MTextPreviewState, event: &iced::Event, bounds: iced::Rectangle, cursor: iced::mouse::Cursor, ) -> Option> { use iced::mouse::{Button, Event as Me}; use iced::widget::canvas::Action; use iced::Event; match event { Event::Mouse(Me::ButtonPressed(Button::Left)) => { if let Some(p) = cursor.position_in(bounds) { state.dragging = true; let off = self.offset_at(p); return Some(Action::publish(Message::MTextSelStart(off)).and_capture()); } } Event::Mouse(Me::CursorMoved { .. }) => { if state.dragging { if let Some(p) = cursor.position_in(bounds) { let off = self.offset_at(p); return Some(Action::publish(Message::MTextSelTo(off))); } } } Event::Mouse(Me::ButtonReleased(Button::Left)) => { if state.dragging { state.dragging = false; return Some(Action::capture()); } } _ => {} } None } fn draw( &self, _state: &MTextPreviewState, renderer: &iced::Renderer, _theme: &Theme, bounds: iced::Rectangle, _cursor: iced::mouse::Cursor, ) -> Vec { use iced::widget::canvas::{Frame, Path, Stroke}; let mut frame = Frame::new(renderer, bounds.size()); let pad = MTEXT_PREVIEW_PAD; // Draw at the real size; flip Y (world up → screen down). let map = |x: f32, y: f32| { iced::Point::new( pad + (x - self.minx) * self.scale, self.content_h - (pad + (y - self.miny) * self.scale), ) }; // Selection highlight behind the glyphs. if let Some((a, b)) = self.sel { for bx in &self.boxes { if bx.vis >= a && bx.vis < b { let p0 = map(bx.xmin, bx.ymax); let p1 = map(bx.xmax, bx.ymin); let rect = Path::rectangle( iced::Point::new(p0.x.min(p1.x), p0.y.min(p1.y)), iced::Size::new((p1.x - p0.x).abs(), (p1.y - p0.y).abs()), ); frame.fill( &rect, Color { r: 0.20, g: 0.42, b: 0.72, a: 0.45, }, ); } } } for (seg, col, width) 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(*width)); } // 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) and a /// stroke width (bold runs carry a wider pen via `line_weight_px`). fn mtext_preview_segments( ed: &super::super::mtext_editor::MTextEditorState, ) -> Vec<(Vec<(f32, f32)>, Color, f32)> { let mut out: Vec<(Vec<(f32, f32)>, Color, f32)> = 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, }; // Bold text wires bake a wider pen (line_weight_px ~2.4); draw them thick. let width = if w.line_weight_px > 1.5 { 2.6 } else { 1.4 }; 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, width)); } } if !run.is_empty() { out.push((run, col, width)); } } out } pub(super) fn mtext_editor_overlay<'a>( ed: &'a super::super::mtext_editor::MTextEditorState, styles: Vec, canvas_size: (f32, f32), ) -> Element<'a, Message> { use super::super::mtext_editor::{JustifyChoice, MTextFmt, ParaAlign}; use iced::widget::{canvas, svg}; const PANEL_BG: Color = Color { r: 0.16, g: 0.16, b: 0.16, a: 0.98, }; const BORDER: Color = Color { r: 0.40, g: 0.40, b: 0.40, a: 1.0, }; const TEXT_COL: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0, }; const FIELD_BG: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0, }; let btn_style = |_: &Theme, status: button::Status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => Color { r: 0.28, g: 0.40, b: 0.55, a: 1.0, }, _ => Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0, }, })), text_color: TEXT_COL, border: Border { color: BORDER, width: 1.0, radius: 3.0.into(), }, shadow: iced::Shadow::default(), snap: false, }; let icon_btn = move |bytes: &'static [u8], msg: Message| -> Element<'static, Message> { button(svg(svg::Handle::from_memory(bytes)).width(18).height(18)) .on_press(msg) .padding(3) .style(btn_style) .into() }; let lbl = |s: &'static str| text(s).size(11).color(TEXT_COL); let small_input = |placeholder: &'static str, val: &str, on: fn(String) -> Message, w: f32| -> Element<'static, Message> { text_input(placeholder, val) .on_input(on) .width(iced::Length::Fixed(w)) .padding(3) .size(12) .into() }; // ── Row 1: style / font / height · format icons · colour ────────────── let style_opts: Vec = if styles.is_empty() { vec!["Standard".to_string()] } else { styles }; let style_pl = pick_list(style_opts, Some(ed.style.clone()), Message::MTextStyle) .text_size(11) .width(iced::Length::Fixed(96.0)); let font_sel = if ed.font.trim().is_empty() { "[Style default]".to_string() } else { ed.font.clone() }; let font_pl = pick_list( MTEXT_FONTS .iter() .map(|s| s.to_string()) .collect::>(), Some(font_sel), Message::MTextFont, ) .text_size(11) .width(iced::Length::Fixed(120.0)); // Same colour picker as the Properties panel (named swatches + "More…" full // palette), applied to the selection or the whole text. let color_pl = iced::widget::container(crate::ui::color_select::color_selector( acadrust::types::Color::from_index(ed.color_aci as i16), ed.color_picker_open, crate::ui::color_select::ColorExtras { by_layer: true, by_block: false, }, Message::MTextColorChanged, Message::MTextColorPickerToggle, Message::OpenColorWindow(crate::app::ColorPickTarget::MText), )) .width(iced::Length::Fixed(150.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) ──────── // ── Body: the rendered preview (the editor is preview-only) ───────── const VIEW_H: f32 = 150.0; let body: Element<'a, Message> = { 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() }; let panel = container(column![row1, row2, 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 ────────────────────────────────────── pub(super) fn viewport_context_menu_overlay( pos: iced::Point, has_cmd: bool, has_selection: bool, isolation_active: bool, last_cmds: Vec, draworder_open: bool, ) -> Element<'static, Message> { const MENU_BG: Color = Color { r: 0.17, g: 0.17, b: 0.17, a: 1.0, }; const MENU_BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0, }; const ITEM_HOVER: Color = Color { r: 0.25, g: 0.45, b: 0.70, a: 1.0, }; const TEXT_COL: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0, }; const SEP_COL: Color = Color { r: 0.30, g: 0.30, b: 0.30, a: 1.0, }; let item = |label: String, msg: Message| -> Element<'static, Message> { button(text(label).size(12).color(TEXT_COL)) .on_press(msg) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => ITEM_HOVER, _ => Color::TRANSPARENT, })), text_color: TEXT_COL, border: Border::default(), shadow: iced::Shadow::default(), snap: false, }) .padding([4, 12]) .width(Fill) .into() }; let sep = || -> Element<'static, Message> { container(iced::widget::Space::new().width(Fill).height(1)) .style(move |_: &Theme| container::Style { background: Some(Background::Color(SEP_COL)), ..Default::default() }) .width(Fill) .height(1) .padding([0, 6]) .into() }; // Indented variant for sub-menu rows (e.g. Draw Order children). let subitem = |label: String, msg: Message| -> Element<'static, Message> { button(text(label).size(12).color(TEXT_COL)) .on_press(msg) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => ITEM_HOVER, _ => Color::TRANSPARENT, })), text_color: TEXT_COL, border: Border::default(), shadow: iced::Shadow::default(), snap: false, }) .padding(iced::Padding { top: 4.0, right: 12.0, bottom: 4.0, left: 26.0, }) .width(Fill) .into() }; let mut items: Vec> = Vec::new(); if has_cmd { items.push(item("Cancel".to_string(), Message::CommandEscape)); items.push(item("Enter".to_string(), Message::CommandFinalize)); } else { if !last_cmds.is_empty() { let last = last_cmds[0].clone(); items.push(item( format!("Repeat {last}"), Message::Command(last.to_uppercase()), )); if last_cmds.len() > 1 { for cmd in last_cmds.iter().skip(1) { let c = cmd.clone(); items.push(item(c.clone(), Message::Command(c.to_uppercase()))); } } items.push(sep()); } if has_selection { items.push(item("Delete".to_string(), Message::DeleteSelected)); items.push(item( "Move".to_string(), Message::Command("MOVE".to_string()), )); items.push(item( "Copy".to_string(), Message::Command("COPY".to_string()), )); items.push(sep()); let do_caret = if draworder_open { crate::ui::icons::arrow_down(9.0, TEXT_COL) } else { crate::ui::icons::arrow_right(9.0, TEXT_COL) }; items.push( button( row![ text("Draw Order").size(12).color(TEXT_COL), iced::widget::Space::new().width(Fill), do_caret, ] .align_y(iced::Center), ) .on_press(Message::DrawOrderSubmenuToggle) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => ITEM_HOVER, _ => Color::TRANSPARENT, })), text_color: TEXT_COL, border: Border::default(), shadow: iced::Shadow::default(), snap: false, }) .padding([4, 12]) .width(Fill) .into(), ); if draworder_open { items.push(subitem( "Bring to Front".to_string(), Message::Command("DRAWORDER F".to_string()), )); items.push(subitem( "Send to Back".to_string(), Message::Command("DRAWORDER B".to_string()), )); items.push(subitem( "Bring Above Object".to_string(), Message::DrawOrderPickRef(true), )); items.push(subitem( "Send Under Object".to_string(), Message::DrawOrderPickRef(false), )); } items.push(sep()); items.push(item( "Isolate Objects".to_string(), Message::Command("ISOLATEOBJECTS".to_string()), )); items.push(item( "Hide Objects".to_string(), Message::Command("HIDEOBJECTS".to_string()), )); items.push(sep()); items.push(item("Select Similar".to_string(), Message::SelectSimilar)); items.push(item( "Invert Selection".to_string(), Message::InvertSelection, )); } if isolation_active { items.push(item( "End Object Isolation".to_string(), Message::Command("UNISOLATEOBJECTS".to_string()), )); } items.push(item( "Select All".to_string(), Message::Command("SELECTALL".to_string()), )); items.push(item("Quick Select...".to_string(), Message::QSelectOpen)); items.push(item( "Zoom Extents".to_string(), Message::Command("ZOOM EXTENTS".to_string()), )); } let menu_col = column(items).spacing(0).width(iced::Length::Fixed(180.0)); let menu = container(menu_col) .style(move |_: &Theme| container::Style { background: Some(Background::Color(MENU_BG)), border: Border { color: MENU_BORDER, width: 1.0, radius: 4.0.into(), }, ..Default::default() }) .padding([4, 0]) .width(iced::Length::Fixed(180.0)); position_canvas_overlay(pos, menu.into()) } /// A small right-click context menu rendered above the status bar. /// The `name` is the layout tab that was right-clicked. pub(super) 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. pub(super) fn qselect_overlay<'a>( state: &'a crate::app::QSelectState, types: &[&'static str], properties: &[(String, String)], ) -> Element<'a, Message> { use iced::widget::{checkbox, pick_list}; const BG: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 0.98, }; const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0, }; const TEXT: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0, }; const BTN_OK: Color = Color { r: 0.22, g: 0.42, b: 0.68, a: 1.0, }; const BTN_OK_HOV: Color = Color { r: 0.30, g: 0.52, b: 0.80, a: 1.0, }; const BTN_BG: Color = Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0, }; const BTN_HOV: Color = Color { r: 0.30, g: 0.30, b: 0.30, a: 1.0, }; let mut type_options: Vec = vec![QSELECT_ANY_TYPE.to_string()]; type_options.extend(types.iter().map(|s| (*s).to_string())); let mut prop_options: Vec = vec![crate::app::QSelectPropertyChoice { field: String::new(), label: QSELECT_ANY_PROP.to_string(), }]; prop_options.extend(properties.iter().map(|(field, label)| { crate::app::QSelectPropertyChoice { field: field.clone(), label: label.clone(), } })); let op_options: Vec = vec![ crate::app::QSelectOp::Eq, crate::app::QSelectOp::Neq, crate::app::QSelectOp::Gt, crate::app::QSelectOp::Lt, crate::app::QSelectOp::Any, ]; let type_sel = state .type_filter .clone() .unwrap_or_else(|| QSELECT_ANY_TYPE.to_string()); let prop_sel = state .property .clone() .unwrap_or(crate::app::QSelectPropertyChoice { field: String::new(), label: QSELECT_ANY_PROP.to_string(), }); // The value field is disabled (visually de-emphasised; we still // render the same widget) when no property is picked or the // operator is "*Any value" — both of those skip the value test. let value_enabled = state.property.is_some() && !matches!(state.operator, crate::app::QSelectOp::Any); let label = |s: &'static str| { text(s) .size(12) .color(TEXT) .width(iced::Length::Fixed(90.0)) }; let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| { button(text(lbl).size(12).color(TEXT)) .on_press(msg) .style(move |_: &Theme, st| button::Style { background: Some(Background::Color( if matches!(st, button::Status::Hovered | button::Status::Pressed) { hov } else { base }, )), text_color: TEXT, border: Border { color: BORDER, width: 1.0, radius: 4.0.into(), }, ..Default::default() }) .padding([4, 14]) }; let mut value_input = text_input("", &state.value).size(12); if value_enabled { value_input = value_input.on_input(Message::QSelectSetValue); } let panel_body = column![ text("Quick Select").size(14).color(TEXT), Space::new().height(10), row![ label("Object type:"), pick_list(type_options, Some(type_sel), |s: String| { if s == QSELECT_ANY_TYPE { Message::QSelectSetType(None) } else { Message::QSelectSetType(Some(s)) } }) .width(Fill), ] .align_y(iced::Alignment::Center) .spacing(8), Space::new().height(6), row![ label("Property:"), pick_list( prop_options, Some(prop_sel), |p: crate::app::QSelectPropertyChoice| { if p.field.is_empty() { Message::QSelectSetProperty(None) } else { Message::QSelectSetProperty(Some(p)) } } ) .width(Fill), ] .align_y(iced::Alignment::Center) .spacing(8), Space::new().height(6), row![ label("Operator:"), pick_list( op_options, Some(state.operator), Message::QSelectSetOperator ) .width(Fill), ] .align_y(iced::Alignment::Center) .spacing(8), Space::new().height(6), row![label("Value:"), value_input,] .align_y(iced::Alignment::Center) .spacing(8), Space::new().height(10), row![ checkbox(state.append) .on_toggle(Message::QSelectSetAppend) .size(14), Space::new().width(6), text("Append to current selection").size(12).color(TEXT), ] .align_y(iced::Alignment::Center), Space::new().height(14), row![ Space::new().width(Fill), btn("Cancel", Message::QSelectClose, BTN_BG, BTN_HOV), Space::new().width(8), btn("Apply", Message::QSelectApply, BTN_OK, BTN_OK_HOV), ] .align_y(iced::Alignment::Center), ] .spacing(0); let panel = container(panel_body) .padding(16) .width(iced::Length::Fixed(400.0)) .style(|_: &Theme| container::Style { background: Some(Background::Color(BG)), border: Border { color: BORDER, width: 1.0, radius: 6.0.into(), }, ..Default::default() }); // Outside-click catcher — fills the whole screen, sits below the // panel. The panel itself is rendered above and absorbs its own // clicks via standard widget event handling. let catcher = mouse_area( container(iced::widget::Space::new().width(Fill).height(Fill)) .width(Fill) .height(Fill), ) .on_press(Message::QSelectClose) .on_right_press(Message::QSelectClose); let centered = container(iced::widget::opaque(panel)).center(Fill); stack![catcher, centered].into() }