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; 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; 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) { 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(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. fn position_canvas_overlay_clamped<'a>( anchor: iced::Point, bottom_inset: f32, 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, }) } /// 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, }) } struct ClampedPin<'a> { content: Element<'a, Message>, anchor: iced::Point, bottom_inset: f32, gap: f32, } 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 max_x = (max.width - MARGIN - content.width).max(MARGIN); let right = self.anchor.x + self.gap; 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 - self.bottom_inset - MARGIN - content.height).max(MARGIN); let below = self.anchor.y + self.gap; 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 ────────────────────────────────────── pub(super) fn viewport_context_menu_overlay( pos: iced::Point, bottom_inset: f32, has_cmd: bool, has_selection: bool, isolation_active: bool, last_cmds: Vec, draworder_open: bool, ) -> Element<'static, Message> { let item = |label: String, msg: Message| -> Element<'static, Message> { button(text(label).size(12)) .on_press(msg) .style(button::subtle) .padding([4, 12]) .width(Fill) .into() }; 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() }; // 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)) .on_press(msg) .style(button::subtle) .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(t!("Cancel").into_owned(), Message::CommandEscape)); items.push(item(t!("Enter").into_owned(), Message::CommandFinalize)); } else { if !last_cmds.is_empty() { let last = last_cmds[0].clone(); items.push(item( t!("Repeat %{last}", last = last).into_owned(), 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(t!("Delete").into_owned(), Message::DeleteSelected)); items.push(item( t!("Move").into_owned(), Message::Command("MOVE".to_string()), )); items.push(item( t!("Copy").into_owned(), Message::Command("COPY".to_string()), )); items.push(sep()); let do_caret = if draworder_open { crate::ui::icons::themed_arrow_down(9.0) } else { crate::ui::icons::themed_arrow_right(9.0) }; items.push( button( row![ text(t!("Draw Order").into_owned()).size(12), iced::widget::Space::new().width(Fill), do_caret, ] .align_y(iced::Center), ) .on_press(Message::DrawOrderSubmenuToggle) .style(button::subtle) .padding([4, 12]) .width(Fill) .into(), ); if draworder_open { items.push(subitem( t!("Bring to Front").into_owned(), Message::Command("DRAWORDER F".to_string()), )); items.push(subitem( t!("Send to Back").into_owned(), Message::Command("DRAWORDER B".to_string()), )); items.push(subitem( t!("Bring Above Object").into_owned(), Message::DrawOrderPickRef(true), )); items.push(subitem( t!("Send Under Object").into_owned(), Message::DrawOrderPickRef(false), )); } items.push(sep()); items.push(item( t!("Isolate Objects").into_owned(), Message::Command("ISOLATEOBJECTS".to_string()), )); items.push(item( t!("Hide Objects").into_owned(), Message::Command("HIDEOBJECTS".to_string()), )); items.push(sep()); items.push(item(t!("Select Similar").into_owned(), Message::SelectSimilar)); items.push(item( t!("Invert Selection").into_owned(), Message::InvertSelection, )); } if isolation_active { items.push(item( t!("End Object Isolation").into_owned(), Message::Command("UNISOLATEOBJECTS".to_string()), )); } items.push(item( t!("Select All").into_owned(), Message::Command("SELECTALL".to_string()), )); items.push(item(t!("Quick Select...").into_owned(), Message::QSelectOpen)); items.push(item( t!("Zoom Extents").into_owned(), Message::Command("ZOOM EXTENTS".to_string()), )); } let menu_col = column(items).spacing(0).width(Length::Fixed(180.0)); let menu_col = scrollable(menu_col) .height(Length::Shrink) .direction(scrollable::Direction::Vertical( scrollable::Scrollbar::new() .width(8) .scroller_width(6), )); let menu = container(menu_col) .style(container::bordered_box) .padding([4, 0]) .width(Length::Fixed(180.0)); position_canvas_overlay_clamped(pos, bottom_inset, menu.into()) } /// 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. pub(super) fn snap_override_overlay(pos: iced::Point) -> Element<'static, Message> { const COLS: usize = 4; let cell = |snap_type: crate::snap::SnapType, label: &'static str| -> Element<'static, Message> { let icon = container(crate::ui::icons::themed::( crate::ui::icons::osnap(snap_type), 16.0, )) .width(26) .height(26) .align_x(iced::Center) .align_y(iced::Center); let btn = button(icon) .on_press(Message::SnapOverridePick(snap_type)) .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() }; let mut grid = column![].spacing(2); for chunk in crate::snap::ALL_SNAP_MODES.chunks(COLS) { let mut r = row![].spacing(2); for &(snap_type, _glyph, label) in chunk { r = r.push(cell(snap_type, label)); } 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() }