cad-editor/src/app/view/overlay.rs
Hakan Seven 796d7d91d0 fix(ui): auto-size modal dialogs
Measure dialog content before fill layout so fields stay visible.\nKeep the MText preview width tied to its toolbar width.\n\nRefs #608
2026-08-01 18:24:05 +03:00

1224 lines
42 KiB
Rust

use super::super::Message;
use iced::widget::{
button, column, container, mouse_area, 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> {
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("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: &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 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.
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<crate::entities::text_support::GlyphBox>,
/// Current selection as a visible-char range.
sel: Option<(usize, usize)>,
/// Caret position as a visible-char offset.
caret: usize,
/// Whether the caret is in its visible blink phase.
caret_on: bool,
/// World-space min corner (bbox) and pixels-per-world-unit scale.
minx: f32,
miny: f32,
scale: f32,
content_h: f32,
/// 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<Message> for MTextPreview {
type State = MTextPreviewState;
fn update(
&self,
state: &mut MTextPreviewState,
event: &iced::Event,
bounds: iced::Rectangle,
cursor: iced::mouse::Cursor,
) -> Option<iced::widget::canvas::Action<Message>> {
use iced::mouse::{Button, Event as Me};
use iced::widget::canvas::Action;
use iced::Event;
match event {
Event::Mouse(Me::ButtonPressed(Button::Left)) => {
if let Some(p) = cursor.position_in(bounds) {
state.dragging = true;
let off = self.offset_at(p);
return Some(Action::publish(Message::MTextSelStart(off)).and_capture());
}
}
Event::Mouse(Me::CursorMoved { .. }) => {
if state.dragging {
if let Some(p) = cursor.position_in(bounds) {
let off = self.offset_at(p);
return Some(Action::publish(Message::MTextSelTo(off)));
}
}
}
Event::Mouse(Me::ButtonReleased(Button::Left)) => {
if state.dragging {
state.dragging = false;
return Some(Action::capture());
}
}
_ => {}
}
None
}
fn draw(
&self,
_state: &MTextPreviewState,
renderer: &iced::Renderer,
theme: &Theme,
bounds: iced::Rectangle,
_cursor: iced::mouse::Cursor,
) -> Vec<iced::widget::canvas::Geometry> {
use iced::widget::canvas::{Frame, Path, Stroke};
let mut frame = Frame::new(renderer, bounds.size());
let pad = MTEXT_PREVIEW_PAD;
// Draw at the real size; flip Y (world up → screen down).
let map = |x: f32, y: f32| {
iced::Point::new(
pad + (x - self.minx) * self.scale,
self.content_h - (pad + (y - self.miny) * self.scale),
)
};
// 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<String>,
modal_offset: iced::Vector,
modal_resize: iced::Vector,
modal_content_size: Option<iced::Size>,
) -> 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),
"Text Editor",
content,
Message::MTextCancel,
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<String> = 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 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::<Vec<_>>(),
|value| value.to_string(),
)
.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,
},
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,
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![
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),
]
.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(format!("Width: {:.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 on the right, exactly like the style managers'
// toolbar strip. Closing (the modal ✕) without applying discards the
// buffer, so there is no separate Cancel.
let action_bar = container(
row![
iced::widget::Space::new().width(width),
crate::ui::style::style_manager::tb_button("Apply", Message::MTextApply, 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, width_slider, body]
.spacing(6)
.width(width)
.height(height),
)
.width(width)
.height(height)
.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<String>,
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<Element<'static, Message>> = Vec::new();
if has_cmd {
items.push(item("Cancel".to_string(), Message::CommandEscape));
items.push(item("Enter".to_string(), Message::CommandFinalize));
} else {
if !last_cmds.is_empty() {
let last = last_cmds[0].clone();
items.push(item(
format!("Repeat {last}"),
Message::Command(last.to_uppercase()),
));
if last_cmds.len() > 1 {
for cmd in last_cmds.iter().skip(1) {
let c = cmd.clone();
items.push(item(c.clone(), Message::Command(c.to_uppercase())));
}
}
items.push(sep());
}
if has_selection {
items.push(item("Delete".to_string(), Message::DeleteSelected));
items.push(item(
"Move".to_string(),
Message::Command("MOVE".to_string()),
));
items.push(item(
"Copy".to_string(),
Message::Command("COPY".to_string()),
));
items.push(sep());
let do_caret = if draworder_open {
crate::ui::icons::themed_arrow_down(9.0)
} else {
crate::ui::icons::themed_arrow_right(9.0)
};
items.push(
button(
row![
text("Draw Order").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(
"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(container::bordered_box)
.padding([4, 0])
.width(iced::Length::Fixed(180.0));
position_canvas_overlay(pos, 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::<Message>(
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)";
/// 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: &[String],
properties: &[(String, String)],
modal_offset: iced::Vector,
modal_resize: iced::Vector,
) -> Element<'a, Message> {
let measurement = qselect_content(
state,
types,
properties,
crate::ui::modal::ModalSizing::INTRINSIC,
);
let content = qselect_content(
state,
types,
properties,
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),
"Quick Select",
content,
Message::QSelectClose,
modal_offset,
crate::ui::modal::ModalOptions::STANDARD,
)
}
fn qselect_content<'a>(
state: &'a crate::app::QSelectState,
types: &[String],
properties: &[(String, String)],
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
use iced::widget::{checkbox};
let mut type_options: Vec<String> = vec![QSELECT_ANY_TYPE.to_string()];
type_options.extend(types.iter().cloned());
let mut prop_options: Vec<crate::app::QSelectPropertyChoice> =
vec![crate::app::QSelectPropertyChoice {
field: String::new(),
label: QSELECT_ANY_PROP.to_string(),
}];
prop_options.extend(properties.iter().map(|(field, label)| {
crate::app::QSelectPropertyChoice {
field: field.clone(),
label: label.clone(),
}
}));
let op_options: Vec<crate::app::QSelectOp> = vec![
crate::app::QSelectOp::Eq,
crate::app::QSelectOp::Neq,
crate::app::QSelectOp::Gt,
crate::app::QSelectOp::Lt,
crate::app::QSelectOp::Any,
];
let type_sel = state
.type_filter
.clone()
.unwrap_or_else(|| QSELECT_ANY_TYPE.to_string());
let prop_sel = state
.property
.clone()
.unwrap_or(crate::app::QSelectPropertyChoice {
field: String::new(),
label: QSELECT_ANY_PROP.to_string(),
});
// The value field is disabled (visually de-emphasised; we still
// render the same widget) when no property is picked or the
// operator is "*Any value" — both of those skip the value test.
let value_enabled =
state.property.is_some() && !matches!(state.operator, crate::app::QSelectOp::Any);
let field_width = if matches!(sizing.width, iced::Length::Fill) {
Fill
} else {
iced::Length::Shrink
};
let label = |s: &'static str| {
text(s)
.size(12)
.width(iced::Length::Fixed(90.0))
};
let btn = |lbl: &'static str, msg: Message, primary: bool| {
button(text(lbl).size(12))
.on_press(msg)
.style(move |theme: &Theme, st| {
let palette = theme.palette();
let pair = match (
primary,
matches!(st, button::Status::Hovered | button::Status::Pressed),
) {
(true, true) => palette.primary.strong,
(true, false) => palette.primary.base,
(false, true) => palette.background.strong,
(false, false) => 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: 4.0.into(),
},
..Default::default()
}
})
.padding([4, 14])
};
let mut value_input = text_input("", &state.value)
.size(12)
.width(field_width);
if value_enabled {
value_input = value_input.on_input(Message::QSelectSetValue);
}
let panel_body = column![
row![
label("Object type:"),
iced::widget::pick_list(
Some(type_sel),
type_options,
|value| value.to_string(),
)
.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("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("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("Value:"), value_input,]
.align_y(iced::Alignment::Center)
.spacing(8)
.width(sizing.width),
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),
]
.align_y(iced::Alignment::Center),
Space::new().height(14),
row![
Space::new().width(field_width),
btn("Cancel", Message::QSelectClose, false),
Space::new().width(8),
btn("Apply", Message::QSelectApply, true),
]
.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()
}