Files
OneCAD-Draft/src/app/view/overlay.rs
T
PanosandClaude Opus 5 0fbb5bcb4f ui: neutral wording in code and help text; keywords first in the command menu
Comments and the Options help text describe the behaviour of commercial
solutions without naming any product. The command menu puts the command's
keywords directly under Enter / Cancel, with Recent Input and Snap
Overrides below them, so the choices that change from step to step are the
closest to the pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:05:15 +03:00

1792 lines
61 KiB
Rust

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<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;
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<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) {
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<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),
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<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 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::<Vec<_>>(),
|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::<Vec<_>>(),
|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<Message, Theme, iced::Renderer> 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<Length> {
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<overlay::Element<'b, Message, Theme, iced::Renderer>> {
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<MenuIcon>) -> 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::<Message>(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<usize>,
) -> 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<Element<'static, Message>> = 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::<Message>(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<Element<'static, Message>> = 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<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: 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<crate::app::QSelectOp> = 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()
}