use super::super::Message; use iced::advanced::layout; use iced::advanced::mouse; use iced::advanced::overlay; use iced::advanced::renderer; use iced::advanced::widget; use iced::advanced::{Layout, Shell, Widget}; use iced::widget::{ button, checkbox, column, container, mouse_area, row, scrollable, stack, text, text_input, Space, }; use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector}; use crate::t; use crate::ui::popup::context_menu::{ContextMenu, MenuIcon, MenuItem, MenuRow, MENU_PAD_TOP, MENU_ROW_H}; pub(super) fn position_canvas_overlay<'a>( anchor: iced::Point, panel: Element<'a, Message>, ) -> Element<'a, Message> { iced::widget::pin(iced::widget::opaque(panel)) .position(iced::Point::new(anchor.x.max(0.0), anchor.y.max(0.0))) .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> { let field = text_input(t!("Text").as_ref(), &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: &Theme| { let palette = theme.palette(); container::Style { background: Some(Background::Color(palette.background.weak.color)), border: Border { color: palette.background.neutral.color, 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 bundled with the renderer; 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. pub(in crate::app) const MTEXT_PREVIEW_PAD: f32 = 12.0; pub(in crate::app) const MTEXT_PREVIEW_EM_PX: f32 = 15.0; // Used only until the shared modal sensor reports the first real layout width. const MTEXT_EDITOR_FALLBACK_WIDTH: f32 = 660.0; pub(in crate::app) const MTEXT_EDITOR_WRITING_WIDTH: f32 = MTEXT_EDITOR_FALLBACK_WIDTH - 2.0 * MTEXT_PREVIEW_PAD; 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, /// On-screen width of the MText wrapping rectangle. wrap_width_px: 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; if b.is_rtl { if wx < (b.xmin + b.xmax) * 0.5 { best = b.vis + 1; } else { best = b.vis; } } else { 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), ) }; // Wrap ruler: same screen-space position as the width slider handle. let wrap_x = (pad + self.wrap_width_px).clamp(pad, bounds.width.max(pad)); let wrap_line = Path::new(|path| { path.move_to(iced::Point::new(wrap_x, 0.0)); path.line_to(iced::Point::new(wrap_x, self.content_h)); }); frame.stroke( &wrap_line, Stroke::default() .with_color(theme.palette().primary.base.color.scale_alpha(0.65)) .with_width(1.0), ); // 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, theme.palette().primary.base.color.scale_alpha(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. // Its height matches the preview's constant em (see EM_PX) so the // empty-editor caret isn't tiny. 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 + 40.0).min(self.content_h), )); }); frame.stroke( &path, Stroke::default() .with_color(theme.palette().warning.base.color) .with_width(1.5), ); } else if collapsed { let bar = if let Some(b) = self.boxes.iter().find(|b| b.vis == self.caret) { let cx = if b.is_rtl { b.xmax } else { b.xmin }; Some((cx, b.ymin, b.ymax)) } else if self.caret > 0 { self.boxes .iter() .find(|b| b.vis == self.caret - 1) .map(|b| { let cx = if b.is_rtl { b.xmin } else { b.xmax }; (cx, b.ymin, b.ymax) }) } else { self.boxes.first().map(|b| { let cx = if b.is_rtl { b.xmax } else { b.xmin }; (cx, 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(theme.palette().warning.base.color) .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, modal_offset: iced::Vector, modal_resize: iced::Vector, modal_content_size: Option, ) -> Element<'a, Message> { let writing_area_px = modal_content_size .map(|size| size.width - 2.0 * MTEXT_PREVIEW_PAD) .unwrap_or(MTEXT_EDITOR_WRITING_WIDTH) .max(80.0); let measurement = mtext_editor_content( ed, &styles, crate::ui::modal::ModalSizing::INTRINSIC, writing_area_px, (writing_area_px * 0.5).max(1.0), ); let content = mtext_editor_content( ed, &styles, crate::ui::modal::ModalSizing::FILL, writing_area_px, (writing_area_px * 0.5).max(1.0), ); let content = crate::ui::modal::intrinsic( measurement, content, iced::Size::INFINITE, modal_resize, ); crate::ui::modal::modal( iced::widget::Space::new().width(Fill).height(Fill), t!("Text Editor"), content, Message::MTextOk, modal_offset, crate::ui::modal::ModalOptions::STANDARD, ) } fn mtext_editor_content<'a>( ed: &'a super::super::mtext_editor::MTextEditorState, styles: &[String], sizing: crate::ui::modal::ModalSizing, writing_area_px: f32, intrinsic_preview_height: f32, ) -> Element<'a, Message> { let width = sizing.width; let height = sizing.height; let preview_height = if matches!(height, iced::Length::Fill) { iced::Length::Fill } else { iced::Length::Fixed(intrinsic_preview_height) }; use super::super::mtext_editor::{JustifyChoice, MTextFmt, ParaAlign}; use iced::widget::canvas; let btn_style = |theme: &Theme, status: button::Status| { let palette = theme.palette(); let pair = match status { button::Status::Hovered | button::Status::Pressed => palette.background.strong, _ => palette.background.weak, }; button::Style { background: Some(Background::Color(pair.color)), text_color: pair.text, border: Border { color: palette.background.neutral.color, 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(crate::ui::icons::themed(bytes, 18.0)) .on_press(msg) .padding(3) .style(btn_style) .into() }; let lbl = |s: &'static str| text(s).size(11); 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.to_vec() }; let style_pl = iced::widget::pick_list( Some(ed.style.clone()), style_opts, |value| value.to_string(), ) .on_select(Message::MTextStyle) .text_size(11) .width(iced::Length::Fixed(96.0)); let annotative = checkbox(ed.annotative) .on_toggle(Message::MTextAnnotative) .size(14); let font_sel = if ed.font.trim().is_empty() { "[Style default]".to_string() } else { ed.font.clone() }; let font_pl = iced::widget::pick_list( Some(font_sel), MTEXT_FONTS .iter() .map(|s| s.to_string()) .collect::>(), |value| t!(value).into_owned(), ) .on_select(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, ..Default::default() }, Message::MTextColorChanged, Message::MTextColorPickerToggle, Message::OpenColorWindow( crate::app::ColorPickTarget::MText, acadrust::types::Color::from_index(ed.color_aci as i16), ), )) .width(iced::Length::Fixed(150.0)); let row1 = row![ style_pl, row![annotative, text(t!("Annotative")).size(11)] .spacing(3) .align_y(iced::Alignment::Center), 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(width), color_pl, ] .spacing(4) .align_y(iced::Alignment::Center) .width(width); // ── Row 2: oblique / width / char-spacing · align · line spacing · OK ─ let justify = iced::widget::pick_list( Some(JustifyChoice(ed.attachment)), JustifyChoice::ALL, |value| value.to_string(), ) .on_select(|c| Message::MTextJustify(c.0)) .text_size(11) .width(iced::Length::Fixed(112.0)); let row2 = row![ button(lbl("↶")) .on_press(Message::MTextUndo) .padding(3) .style(btn_style), button(lbl("↷")) .on_press(Message::MTextRedo) .padding(3) .style(btn_style), 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), button(lbl("Stack")) .on_press(Message::MTextStack) .padding(3) .style(btn_style), button(lbl("Clear")) .on_press(Message::MTextClearFormatting) .padding(3) .style(btn_style), button(lbl("°")) .on_press(Message::MTextInsert("°".to_string())) .padding(3) .style(btn_style), button(lbl("±")) .on_press(Message::MTextInsert("±".to_string())) .padding(3) .style(btn_style), button(lbl("⌀")) .on_press(Message::MTextInsert("⌀".to_string())) .padding(3) .style(btn_style), ] .spacing(4) .align_y(iced::Alignment::Center) .width(width); let column_mode = match (ed.column_type, ed.column_auto_height) { (1, _) => "Static", (2, true) => "Dynamic auto", (2, false) => "Dynamic manual", _ => "No columns", } .to_string(); let column_picker = iced::widget::pick_list( Some(column_mode), ["No columns", "Static", "Dynamic auto", "Dynamic manual"] .into_iter() .map(str::to_string) .collect::>(), |value| t!(value).into_owned(), ) .on_select(Message::MTextColumnMode) .text_size(11) .width(iced::Length::Fixed(120.0)); let reverse = checkbox(ed.column_flow_reversed) .on_toggle(Message::MTextColumnFlowReversed) .size(14); let row3 = row![ lbl("Columns"), column_picker, lbl("Count"), small_input("2", &ed.column_count, Message::MTextColumnCount, 42.0), lbl("Height"), text_input("0", &ed.rect_height) .on_input(Message::MTextColumnHeight) .width(iced::Length::Fixed(58.0)) .padding(3) .size(12), lbl("Width"), small_input("0", &ed.column_width, Message::MTextColumnWidth, 58.0), lbl("Gutter"), small_input("0", &ed.column_gutter, Message::MTextColumnGutter, 58.0), reverse, lbl("Reverse"), iced::widget::Space::new().width(8), lbl("First"), text_input("0", &ed.paragraph_first_indent) .on_input(|value| Message::MTextParagraphNumber( super::super::mtext_editor::ParaNumber::FirstIndent, value, )) .width(iced::Length::Fixed(48.0)) .padding(3) .size(12), lbl("Left"), text_input("0", &ed.paragraph_left_indent) .on_input(|value| Message::MTextParagraphNumber( super::super::mtext_editor::ParaNumber::LeftIndent, value, )) .width(iced::Length::Fixed(48.0)) .padding(3) .size(12), lbl("Right"), text_input("0", &ed.paragraph_right_indent) .on_input(|value| Message::MTextParagraphNumber( super::super::mtext_editor::ParaNumber::RightIndent, value, )) .width(iced::Length::Fixed(48.0)) .padding(3) .size(12), lbl("Before"), text_input("0", &ed.paragraph_space_before) .on_input(|value| Message::MTextParagraphNumber( super::super::mtext_editor::ParaNumber::SpaceBefore, value, )) .width(iced::Length::Fixed(48.0)) .padding(3) .size(12), lbl("After"), text_input("0", &ed.paragraph_space_after) .on_input(|value| Message::MTextParagraphNumber( super::super::mtext_editor::ParaNumber::SpaceAfter, value, )) .width(iced::Length::Fixed(48.0)) .padding(3) .size(12), ] .spacing(4) .align_y(iced::Alignment::Center) .width(width); let row4 = row![ lbl("Find"), text_input(&t!("Find"), &ed.find_text) .on_input(Message::MTextFindText) .width(iced::Length::Fixed(140.0)) .padding(3) .size(12), button(lbl("Next")) .on_press(Message::MTextFindNext) .padding(3) .style(btn_style), lbl("Replace"), text_input(&t!("Replace"), &ed.replace_text) .on_input(Message::MTextReplaceText) .width(iced::Length::Fixed(140.0)) .padding(3) .size(12), button(lbl("Replace")) .on_press(Message::MTextReplaceNext) .padding(3) .style(btn_style), button(lbl("Replace All")) .on_press(Message::MTextReplaceAll) .padding(3) .style(btn_style), ] .spacing(4) .align_y(iced::Alignment::Center) .width(width); let preview_scale = ed.preview_scale(); let slider_min = 1e-6_f64; let slider_max = f64::from(writing_area_px / preview_scale).max(slider_min * 2.0); let width_slider = column![ row![ text(t!("Width: %{value}", value = format!("{:.3}", ed.rect_width))).size(11), Space::new().width(width), text(format!("{:.0}%", ed.rect_width / slider_max * 100.0)).size(11), ] .width(width), container( iced::widget::slider( slider_min..=slider_max, ed.rect_width.clamp(slider_min, slider_max), Message::MTextRectWidth, ) .step((slider_max * 0.01).max(1e-6)) .width(width), ) .padding([0.0, MTEXT_PREVIEW_PAD]) .width(width), ] .spacing(2) .width(width); // ── Body: the rendered preview (the editor is preview-only). It fills the // space left by the toolbars, so the resizable modal's extra height flows // into the text area. ───────────────────────────────────────────────── let body: Element<'a, Message> = if !matches!(height, iced::Length::Fill) { // The preview is a flexible scroll viewport. Measuring its real canvas // would make long/wide MText dictate the dialog size, so the intrinsic // pass uses an empty viewport proxy. The toolbars alone determine its // width; the preview height follows that measured writing width. container(Space::new()) .style(move |theme: &Theme| { let palette = theme.palette(); container::Style { background: Some(Background::Color(palette.background.base.color)), border: Border { color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, ..Default::default() } }) .padding(2) .width(width) .height(preview_height) .into() } else { 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 scale = preview_scale; let content_h = if maxx >= minx { ((maxy - miny) * scale + 2.0 * MTEXT_PREVIEW_PAD).max(40.0) } else { 40.0 }; // Multi-column MTEXT lays its columns out side by side, so the content // can be wider than the editor; size the canvas to the real content // width and let the scroll area pan horizontally to reach later columns. let content_w = if maxx >= minx { ((maxx - minx).max(ed.rect_width as f32) * scale + 2.0 * MTEXT_PREVIEW_PAD) .max(40.0) } else { ed.rect_width as f32 * scale + 2.0 * MTEXT_PREVIEW_PAD }; 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, wrap_width_px: ed.rect_width as f32 * scale, }; let cv = canvas(prog) .width(iced::Length::Fixed(content_w)) .height(iced::Length::Fixed(content_h)); // The preview fills the space the toolbars leave; it scrolls in BOTH // directions so a taller-than-view text scrolls vertically and a // wider-than-view multi-column text scrolls horizontally. use iced::widget::scrollable::{Direction, Scrollbar}; container( iced::widget::scrollable(cv) .direction(Direction::Both { vertical: Scrollbar::default(), horizontal: Scrollbar::default(), }) .width(width) .height(preview_height), ) .style(move |theme: &Theme| { let palette = theme.palette(); container::Style { background: Some(Background::Color(palette.background.base.color)), border: Border { color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, ..Default::default() } }) .padding(2) .width(width) .height(preview_height) .into() }; // ── Top action bar: Apply keeps editing; Close saves and exits. Escape // remains the explicit discard path. let action_bar = container( row![ iced::widget::Space::new().width(width), crate::ui::style::style_manager::tb_button(t!("Apply"), Message::MTextApply, true), crate::ui::style::style_manager::tb_button(t!("Close Text Editor"), Message::MTextOk, true), ] .align_y(iced::Alignment::Center), ) .style(|theme: &Theme| container::Style { background: Some(Background::Color( theme.palette().background.weak.color )), ..Default::default() }) .width(width) .padding([5, 8]); container( column![action_bar, row1, row2, row3, row4, width_slider, body] .spacing(6) .width(width) .height(height), ) .width(width) .height(height) .into() } /// Position a cursor-anchored panel inside the drawing's safe rectangle. The /// panel is laid out first, so edge flipping uses its actual translated size /// instead of estimates. `bottom_inset` reserves overlaid controls such as the /// command line. /// /// `offset` shifts the preferred placement from the anchor before the fit / /// flip logic runs (e.g. to put a specific row of a menu under the pointer). /// When the shifted panel does not fit it flips to the other side of the /// *original* anchor, so the pointer never ends up over an unintended row. fn position_canvas_overlay_clamped<'a>( anchor: iced::Point, bottom_inset: f32, offset: Vector, panel: Element<'a, Message>, ) -> Element<'a, Message> { Element::new(ClampedPin { content: iced::widget::opaque(panel), anchor, bottom_inset: bottom_inset.max(0.0), gap: 0.0, offset, }) } /// Keep a floating panel close to the cursor without letting it cover the /// pointer or leave the drawing's safe rectangle. pub(super) fn position_canvas_overlay_near_cursor<'a>( cursor: iced::Point, bottom_inset: f32, panel: Element<'a, Message>, ) -> Element<'a, Message> { Element::new(ClampedPin { content: iced::widget::opaque(panel), anchor: cursor, bottom_inset: bottom_inset.max(0.0), gap: 12.0, offset: Vector::ZERO, }) } struct ClampedPin<'a> { content: Element<'a, Message>, anchor: iced::Point, bottom_inset: f32, gap: f32, /// Preferred placement shift from the anchor (applied before fitting). offset: Vector, } impl Widget for ClampedPin<'_> { fn tag(&self) -> widget::tree::Tag { self.content.as_widget().tag() } fn state(&self) -> widget::tree::State { self.content.as_widget().state() } fn diff(&mut self, tree: &mut widget::Tree) { self.content.as_widget_mut().diff(tree); } fn size(&self) -> Size { Size::new(Length::Fill, Length::Fill) } fn layout( &mut self, tree: &mut widget::Tree, renderer: &iced::Renderer, limits: &layout::Limits, ) -> layout::Node { const MARGIN: f32 = 4.0; let max = limits.max(); let safe = Size::new( (max.width - MARGIN * 2.0).max(0.0), (max.height - self.bottom_inset - MARGIN * 2.0).max(0.0), ); let mut node = self.content.as_widget_mut().layout( tree, renderer, &layout::Limits::new(Size::ZERO, safe), ); let content = node.size(); let bottom_inset = self.bottom_inset; let max_x = (max.width - MARGIN - content.width).max(MARGIN); let right = self.anchor.x + self.gap + self.offset.x; let left = self.anchor.x - self.gap - content.width; let x = if right <= max_x { right.max(MARGIN) } else if left >= MARGIN { left } else { max_x }; let max_y = (max.height - bottom_inset - MARGIN - content.height).max(MARGIN); let below = self.anchor.y + self.gap + self.offset.y; let above = self.anchor.y - self.gap - content.height; let y = if below <= max_y { below.max(MARGIN) } else if above >= MARGIN { above } else { max_y }; node = node.move_to(iced::Point::new(x, y)); let size = limits.resolve(Length::Fill, Length::Fill, node.size()); layout::Node::with_children(size, vec![node]) } fn operate( &mut self, tree: &mut widget::Tree, layout: Layout<'_>, renderer: &iced::Renderer, operation: &mut dyn widget::Operation, ) { self.content.as_widget_mut().operate( tree, layout.children().next().unwrap(), renderer, operation, ); } fn update( &mut self, tree: &mut widget::Tree, event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &iced::Renderer, shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) { self.content.as_widget_mut().update( tree, event, layout.children().next().unwrap(), cursor, renderer, shell, viewport, ); } fn mouse_interaction( &self, tree: &widget::Tree, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, renderer: &iced::Renderer, ) -> mouse::Interaction { self.content.as_widget().mouse_interaction( tree, layout.children().next().unwrap(), cursor, viewport, renderer, ) } fn draw( &self, tree: &widget::Tree, renderer: &mut iced::Renderer, theme: &Theme, style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, ) { let bounds = layout.bounds(); if let Some(clipped_viewport) = bounds.intersection(viewport) { self.content.as_widget().draw( tree, renderer, theme, style, layout.children().next().unwrap(), cursor, &clipped_viewport, ); } } fn overlay<'b>( &'b mut self, tree: &'b mut widget::Tree, layout: Layout<'b>, renderer: &iced::Renderer, viewport: &Rectangle, translation: Vector, ) -> Option> { self.content.as_widget_mut().overlay( tree, layout.children().next().unwrap(), renderer, viewport, translation, ) } } // ── Viewport right-click context menu ────────────────────────────────────── /// Horizontal inset so the pointer rests inside the default row, not on the /// panel's border, when the menu opens. const MENU_CURSOR_INSET_X: f32 = 24.0; /// Width of the icon gutter every row reserves so labels line up whether or /// not the row carries a glyph (object snaps, Pan / Zoom). const MENU_GUTTER_W: f32 = 18.0; const MENU_ICON_SIZE: f32 = 14.0; /// The gutter cell: the row's glyph, or empty space of the same width. fn context_menu_gutter(icon: Option) -> Element<'static, Message> { let bytes = icon.map(|icon| match icon { MenuIcon::Snap(t) => crate::ui::icons::osnap(t), MenuIcon::Mtp => crate::ui::icons::mtp_icon(), MenuIcon::Pan => crate::ui::icons::pan_icon(), MenuIcon::Zoom => crate::ui::icons::zoom_icon(), }); let cell: Element<'static, Message> = match bytes { Some(bytes) => crate::ui::icons::themed::(bytes, MENU_ICON_SIZE), None => iced::widget::Space::new().width(MENU_ICON_SIZE).height(MENU_ICON_SIZE).into(), }; container(cell) .width(Length::Fixed(MENU_GUTTER_W)) .align_x(iced::Center) .align_y(iced::Center) .into() } /// Render the right-click context menu (rows from /// `ui::popup::context_menu::build_context_menu`). The panel is placed so the /// default row sits under the pointer: right-click then left-click in place /// completes the step without moving the mouse (commercial solutions anchor their shortcut /// menu the same way). `highlighted` is the keyboard highlight, if any. pub(super) fn viewport_context_menu_overlay( pos: iced::Point, bottom_inset: f32, menu: &ContextMenu, highlighted: Option, ) -> Element<'static, Message> { let sep = || -> Element<'static, Message> { container(iced::widget::Space::new().width(Fill).height(1)) .style(|theme: &Theme| container::Style { background: Some(Background::Color( theme.palette().background.weak.color, )), ..Default::default() }) .width(Fill) .height(1) .padding([0, 6]) .into() }; // Index into `menu.selectable()` of the row being rendered, so the // keyboard highlight lands on the same row the model counts. let mut sel_idx = 0usize; let mut items: Vec> = Vec::new(); for row in &menu.rows { match row { MenuRow::Item(item) => { let is_hl = highlighted == Some(sel_idx); items.push(context_menu_row(item, 0.0, is_hl)); sel_idx += 1; } MenuRow::Separator => items.push(sep()), MenuRow::Submenu { id, label, items: children, open, } => { let is_hl = highlighted == Some(sel_idx); let caret = if *open { crate::ui::icons::themed_arrow_down(9.0) } else { crate::ui::icons::themed_arrow_right(9.0) }; let content = row![ context_menu_gutter(None), text(label.clone()).size(12), iced::widget::Space::new().width(Fill), caret, ] .spacing(4) .align_y(iced::Center); let enabled = !children.is_empty(); let mut btn = button(content) .padding(iced::Padding { top: 3.0, right: 12.0, bottom: 3.0, left: 6.0, }) .width(Fill) .height(Length::Fixed(MENU_ROW_H)) .style(move |theme: &Theme, status| context_menu_row_style(theme, status, is_hl)); if enabled { btn = btn.on_press(Message::ContextMenuSubmenuToggle(*id)); } items.push(btn.into()); sel_idx += 1; if *open { for child in children { let is_hl = highlighted == Some(sel_idx); items.push(context_menu_row(child, 14.0, is_hl)); sel_idx += 1; } } } } } let menu_col = column(items).spacing(0).width(Length::Fixed(menu.width)); let menu_col = scrollable(menu_col) .height(Length::Shrink) .direction(scrollable::Direction::Vertical( scrollable::Scrollbar::new() .width(8) .scroller_width(6), )); let panel = container(menu_col) .style(container::bordered_box) .padding([MENU_PAD_TOP as u16, 0]) .width(Length::Fixed(menu.width)); // Shift the panel so the default row's centre line is under the pointer. let offset = Vector::new( -MENU_CURSOR_INSET_X, -(menu.default_row_y() + MENU_ROW_H * 0.5), ); position_canvas_overlay_clamped(pos, bottom_inset, offset, panel.into()) } /// One menu row: label (bold for the default row, "✓ "-prefixed when /// checked), and the keyword hint right-aligned and dimmed. Disabled rows /// render without a press handler and with faded text. fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element<'static, Message> { let label = if item.checked { format!("✓ {}", item.label) } else { item.label.clone() }; let mut label_text = text(label).size(12); if item.default { label_text = label_text.font(iced::Font { weight: iced::font::Weight::Bold, ..iced::Font::DEFAULT }); } let enabled = item.enabled; let mut content = row![context_menu_gutter(item.icon), label_text] .spacing(4) .align_y(iced::Center); if let Some(hint) = item.hint.as_ref() { content = content .push(iced::widget::Space::new().width(Fill)) .push( text(hint.clone()) .size(11) .style(move |theme: &Theme| { let palette = theme.palette(); let base = if highlighted { palette.primary.strong.text } else { palette.background.base.text }; iced::widget::text::Style { color: Some(base.scale_alpha(if enabled { 0.6 } else { 0.35 })), } }), ); } let mut btn = button(content) .padding(iced::Padding { top: 3.0, right: 12.0, bottom: 3.0, left: 6.0 + indent, }) .width(Fill) .height(Length::Fixed(MENU_ROW_H)) .style(move |theme: &Theme, status| context_menu_row_style(theme, status, highlighted)); if enabled { btn = btn.on_press(Message::ContextMenuPick(item.action.clone())); } btn.into() } /// Row colours: keyboard highlight uses the accent (as the grip popup does), /// hover the subtle background tint, disabled rows faded text. fn context_menu_row_style(theme: &Theme, status: button::Status, highlighted: bool) -> button::Style { let palette = theme.palette(); let (background, text_color) = if highlighted { ( Some(Background::Color(palette.primary.strong.color)), palette.primary.strong.text, ) } else { match status { button::Status::Hovered | button::Status::Pressed => ( Some(Background::Color(palette.background.weak.color)), palette.background.weak.text, ), button::Status::Disabled => (None, palette.background.base.text.scale_alpha(0.42)), button::Status::Active => (None, palette.background.base.text), } }; button::Style { background, text_color, border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 0.0.into(), }, ..Default::default() } } /// One-shot snap override menu (Shift+RMB, #337): a cursor-anchored grid of /// snap ICONS only — the names show as hover tooltips. Picking one applies /// that snap to just the next point pick; the trailing MTP cell instead /// suspends the prompt for two picks and returns their midpoint. pub(super) fn snap_override_overlay(pos: iced::Point) -> Element<'static, Message> { const COLS: usize = 4; let cell_icon = |icon: &'static [u8], label: String, msg: Message| -> Element<'static, Message> { let icon = container(crate::ui::icons::themed::(icon, 16.0)) .width(26) .height(26) .align_x(iced::Center) .align_y(iced::Center); let btn = button(icon) .on_press(msg) .style(|theme: &Theme, status| button::Style { background: matches!( status, button::Status::Hovered | button::Status::Pressed ) .then_some(Background::Color( theme.palette().primary.weak.color )), border: Border::default(), text_color: theme.palette().background.base.text, ..Default::default() }) .padding(2); iced::widget::tooltip( btn, container(text(label).size(11)) .style(|theme: &Theme| { let palette = theme.palette(); container::Style { background: Some(Background::Color(palette.background.strong.color)), border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into(), }, text_color: Some(palette.background.strong.text), ..Default::default() } }) .padding([2, 6]), iced::widget::tooltip::Position::Bottom, ) .into() }; // MTP (`_M2P`) goes last, after Parallel: two picks, so not a // `SnapType`, but same icon-only cell with hover tooltip. let mut cells: Vec> = Vec::with_capacity( crate::snap::ALL_SNAP_MODES.len() + 1, ); for &(snap_type, _glyph, label) in crate::snap::ALL_SNAP_MODES { cells.push(cell_icon( crate::ui::icons::osnap(snap_type), label.to_string(), Message::SnapOverridePick(snap_type), )); } cells.push(cell_icon( crate::ui::icons::mtp_icon(), t!("Mid Between 2 Points (M2P)").into_owned(), Message::SnapOverrideMtp, )); let mut grid = column![].spacing(2); while !cells.is_empty() { let n = cells.len().min(COLS); let mut r = row![].spacing(2); for c in cells.drain(..n) { r = r.push(c); } grid = grid.push(r); } let panel = container(grid) .style(|theme: &Theme| { let palette = theme.palette(); container::Style { background: Some(Background::Color(palette.background.weak.color)), border: Border { color: palette.background.neutral.color, width: 1.0, radius: 4.0.into(), }, ..Default::default() } }) .padding(4); // Full-screen click-catcher closes on an outside click. let catcher = mouse_area( container(iced::widget::Space::new().width(Fill).height(Fill)) .width(Fill) .height(Fill), ) .on_press(Message::SnapOverrideClose) .on_right_press(Message::SnapOverrideClose); stack![catcher, position_canvas_overlay(pos, panel.into())].into() } // ── Quick Select panel ───────────────────────────────────────────────────── const QSELECT_ANY_TYPE: &str = "(Any type)"; const QSELECT_ANY_PROP: &str = "(Any property)"; /// Quick Select uses the application's shared movable modal frame. Its form /// groups scope, filter, and result behavior while reusing the same compact /// field and button styles as the other workspace dialogs. pub(super) fn qselect_overlay<'a>( state: &'a crate::app::QSelectState, types: &[String], properties: &[crate::app::QSelectPropertyChoice], candidate_count: usize, modal_offset: iced::Vector, modal_resize: iced::Vector, ) -> Element<'a, Message> { let measurement = qselect_content( state, types, properties, candidate_count, crate::ui::modal::ModalSizing::INTRINSIC, ); let content = qselect_content( state, types, properties, candidate_count, crate::ui::modal::ModalSizing::FILL, ); let content = crate::ui::modal::intrinsic( measurement, content, iced::Size::INFINITE, modal_resize, ); crate::ui::modal::modal( Space::new().width(Fill).height(Fill), t!("Quick Select"), content, Message::QSelectClose, modal_offset, crate::ui::modal::ModalOptions::STANDARD, ) } fn qselect_content<'a>( state: &'a crate::app::QSelectState, types: &[String], properties: &[crate::app::QSelectPropertyChoice], candidate_count: usize, sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { use iced::widget::{checkbox, radio, rule}; let mut type_options: Vec = vec![QSELECT_ANY_TYPE.to_string()]; type_options.extend(types.iter().cloned()); let mut prop_options: Vec = vec![crate::app::QSelectPropertyChoice { field: String::new(), label: t!(QSELECT_ANY_PROP).into_owned(), editor: crate::app::QSelectValueEditor::Text, }]; prop_options.extend(properties.iter().cloned()); let number_property = state.property.as_ref().is_some_and(|property| { matches!(property.editor, crate::app::QSelectValueEditor::Number) }); let mut op_options: Vec = vec![ crate::app::QSelectOp::Eq, crate::app::QSelectOp::Neq, ]; if number_property { op_options.push(crate::app::QSelectOp::Gt); op_options.push(crate::app::QSelectOp::Lt); } op_options.push(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: t!(QSELECT_ANY_PROP).into_owned(), editor: crate::app::QSelectValueEditor::Text, }); let value_enabled = state.property.is_some() && !matches!(state.operator, crate::app::QSelectOp::Any); let field_width = if matches!(sizing.width, iced::Length::Fill) { Fill } else { iced::Length::Shrink }; let flex_width = if matches!(sizing.width, iced::Length::Fill) { Fill } else { iced::Length::Shrink }; let label = |s: std::borrow::Cow<'static, str>| { text(s) .size(12) .width(iced::Length::Fixed(112.0)) }; let section_label = |s: std::borrow::Cow<'static, str>| { text(s).size(11).style(|theme: &Theme| iced::widget::text::Style { color: Some(theme.palette().background.base.text.scale_alpha(0.65)), }) }; let value_editor: Element<'a, Message> = match state.property.as_ref() { Some(property) => match &property.editor { crate::app::QSelectValueEditor::Choice(options) => { let selected = (!state.value.is_empty()).then(|| state.value.clone()); let picker = iced::widget::pick_list( selected, options.clone(), |value| value.to_string(), ) .width(field_width); if value_enabled { picker.on_select(Message::QSelectSetValue).into() } else { picker.into() } } crate::app::QSelectValueEditor::Text | crate::app::QSelectValueEditor::Number => { let mut input = text_input("", &state.value).size(12).width(field_width); if value_enabled { input = input.on_input(Message::QSelectSetValue); } input.into() } }, None => text_input("", "").size(12).width(field_width).into(), }; let derived_error = if candidate_count == 0 { Some(crate::t!("No objects are available in this scope.").into_owned()) } else if value_enabled && number_property && crate::entities::common::parse_f64(&state.value).is_none() { Some(crate::t!("Enter a valid number.").into_owned()) } else if value_enabled && state.property.as_ref().is_some_and(|property| { matches!(property.editor, crate::app::QSelectValueEditor::Choice(_)) }) && state.value.is_empty() { Some(crate::t!("Choose a value.").into_owned()) } else if matches!(state.operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) && !number_property { Some(crate::t!("This operator requires a numeric property.").into_owned()) } else { None }; let error = state.error.clone().or(derived_error); let scope_options = vec![ crate::app::QSelectScope::CurrentSpace, crate::app::QSelectScope::CurrentSelection, ]; let scope_picker = iced::widget::pick_list( Some(state.scope), scope_options, |value| value.to_string(), ) .on_select(Message::QSelectSetScope) .width(field_width); let mut append = checkbox(state.append).size(14); if matches!(state.scope, crate::app::QSelectScope::CurrentSpace) { append = append.on_toggle(Message::QSelectSetAppend); } let cancel = button(text(t!("Cancel")).size(12)) .on_press(Message::QSelectClose) .style(button::subtle) .padding([5, 16]); let apply = button(text(t!("Apply")).size(12)) .style(button::primary) .padding([5, 18]); let apply = if error.is_none() { apply.on_press(Message::QSelectApply) } else { apply }; let panel_body = column![ section_label(t!("Scope")), Space::new().height(5), row![label(t!("Apply to:")), scope_picker] .align_y(iced::Alignment::Center) .spacing(8) .width(sizing.width), Space::new().height(4), text(crate::tf!("{} candidate object(s)", candidate_count)) .size(11) .style(|theme: &Theme| iced::widget::text::Style { color: Some(theme.palette().background.base.text.scale_alpha(0.65)), }), Space::new().height(10), container(rule::horizontal(1)).width(flex_width), Space::new().height(10), section_label(t!("Filter")), Space::new().height(5), row![ label(t!("Object type:")), iced::widget::pick_list( Some(type_sel), type_options, |value| t!(value).into_owned(), ) .on_select(|s: String| { if s == QSELECT_ANY_TYPE { Message::QSelectSetType(None) } else { Message::QSelectSetType(Some(s)) } }) .width(field_width), ] .align_y(iced::Alignment::Center) .spacing(8) .width(sizing.width), Space::new().height(6), row![ label(t!("Property:")), iced::widget::pick_list( Some(prop_sel), prop_options, |value| value.to_string(), ) .on_select(|p: crate::app::QSelectPropertyChoice| { if p.field.is_empty() { Message::QSelectSetProperty(None) } else { Message::QSelectSetProperty(Some(p)) } }) .width(field_width), ] .align_y(iced::Alignment::Center) .spacing(8) .width(sizing.width), Space::new().height(6), row![ label(t!("Operator:")), iced::widget::pick_list( Some(state.operator), op_options, |value| value.to_string(), ) .on_select(Message::QSelectSetOperator) .width(field_width), ] .align_y(iced::Alignment::Center) .spacing(8) .width(sizing.width), Space::new().height(6), row![label(t!("Value:")), value_editor] .align_y(iced::Alignment::Center) .spacing(8) .width(sizing.width), Space::new().height(10), container(rule::horizontal(1)).width(flex_width), Space::new().height(10), section_label(t!("Result")), Space::new().height(5), column![ radio( t!("Include matching objects"), crate::app::QSelectMode::Include, Some(state.mode), Message::QSelectSetMode, ) .size(14) .text_size(12), radio( t!("Exclude matching objects"), crate::app::QSelectMode::Exclude, Some(state.mode), Message::QSelectSetMode, ) .size(14) .text_size(12), ] .spacing(5), Space::new().height(8), row![ append, Space::new().width(6), text(t!("Append result to current selection")).size(12), ] .align_y(iced::Alignment::Center), Space::new().height(10), if let Some(message) = error { text(message) .size(11) .style(|theme: &Theme| iced::widget::text::Style { color: Some(theme.palette().danger.base.color), }) } else { text("").size(11) }, Space::new().height(8), row![ Space::new().width(flex_width), cancel, Space::new().width(8), apply, ] .align_y(iced::Alignment::Center), ] .spacing(0) .width(sizing.width) .height(sizing.height); let panel = container(panel_body) .padding(16) .width(sizing.width) .height(sizing.height) .style(|theme: &Theme| { let palette = theme.palette(); container::Style { background: Some(Background::Color(palette.background.weak.color)), border: Border { color: palette.background.neutral.color, width: 1.0, radius: 6.0.into(), }, ..Default::default() } }); panel.into() }