forked from 1b-agent/opencadstudio-upstream
feat: Implement in-place MText editor with formatting toolbar and live preview
MTEXT (and double-clicking an MText) now opens an in-place editor anchored at the insertion point instead of typing a single line on the command line. The editor has a two-row formatting toolbar: text style / font / height, bold, italic, underline, overline, strike, upper/lowercase and a colour dropdown on top; oblique angle, width factor, character spacing, paragraph alignment, justification, line spacing and OK/Cancel below. Character toggles wrap the selection in DXF inline codes; global fields apply as leading runs or entity properties. Editing is shown in the editor's own area, not on the drawing: a Code/Preview toggle switches between the raw text input and a rendered preview canvas that draws the tessellated glyph strokes at real size, scrolling when the text outgrows the view. The MText box keeps a fixed width so text wraps to the next line instead of stretching into one line. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
de08bd598c
commit
a5ad0666ce
4 files changed
+75
-43
No files matched your search
@@ -737,6 +737,8 @@ pub enum Message {
|
||||
MTextAlign(mtext_editor::ParaAlign),
|
||||
/// Toolbar line-spacing change.
|
||||
MTextLineSpacing(f32),
|
||||
/// Toggle the editor between the raw code input and the rendered preview.
|
||||
MTextTogglePreview,
|
||||
/// Commit the editor: create or update the MText entity.
|
||||
MTextOk,
|
||||
/// Discard the editor without creating / changing the entity.
|
||||
|
||||
@@ -111,6 +111,9 @@ pub struct MTextEditorState {
|
||||
pub rect_width: f64,
|
||||
/// `Some` when editing an existing entity; `None` for a fresh MText.
|
||||
pub editing: Option<Handle>,
|
||||
/// When true the panel shows the rendered preview; when false the raw
|
||||
/// code/text input. Toggled so the two never stack.
|
||||
pub show_preview: bool,
|
||||
/// Canvas-space anchor where the toolbar + text area are drawn (the
|
||||
/// insertion-point click position).
|
||||
pub screen_anchor: iced::Point,
|
||||
@@ -132,6 +135,7 @@ impl MTextEditorState {
|
||||
width: "1".to_string(),
|
||||
char_space: "0".to_string(),
|
||||
preview_wires: Vec::new(),
|
||||
show_preview: false,
|
||||
attachment: AttachmentPoint::TopLeft,
|
||||
line_spacing: 1.0,
|
||||
// Default box ~20 characters wide; overwritten with the entity's
|
||||
|
||||
@@ -3539,6 +3539,13 @@ impl OpenCADStudio {
|
||||
self.rebuild_mtext_preview();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextTogglePreview => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.show_preview = !ed.show_preview;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextOk => {
|
||||
self.mtext_commit();
|
||||
Task::none()
|
||||
|
||||
+62
-43
@@ -1345,9 +1345,19 @@ const MTEXT_COLORS: [(&str, u16); 8] = [
|
||||
/// Canvas program that renders the tessellated MText strokes inside the
|
||||
/// editor's own preview area (never on the drawing). Strokes lie in the
|
||||
/// world XY plane; the program fits + vertically flips them into the box.
|
||||
const MTEXT_PREVIEW_PAD: f32 = 12.0;
|
||||
/// Screen pixels per world unit of text height — fixes the preview to the
|
||||
/// real text size (so it scrolls rather than shrinking to fit).
|
||||
const MTEXT_PREVIEW_PX_PER_UNIT: f32 = 10.0;
|
||||
|
||||
struct MTextPreview {
|
||||
/// Disconnected polylines as (x, y) world points (NaN-split already done).
|
||||
segments: Vec<Vec<(f32, f32)>>,
|
||||
/// World-space min corner (bbox) and pixels-per-world-unit scale.
|
||||
minx: f32,
|
||||
miny: f32,
|
||||
scale: f32,
|
||||
content_h: f32,
|
||||
}
|
||||
|
||||
impl iced::widget::canvas::Program<Message> for MTextPreview {
|
||||
@@ -1362,30 +1372,13 @@ impl iced::widget::canvas::Program<Message> for MTextPreview {
|
||||
) -> Vec<iced::widget::canvas::Geometry> {
|
||||
use iced::widget::canvas::{Frame, Path, Stroke};
|
||||
let mut frame = Frame::new(renderer, bounds.size());
|
||||
let (mut minx, mut miny, mut maxx, mut maxy) =
|
||||
(f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for seg in &self.segments {
|
||||
for &(x, y) in seg {
|
||||
minx = minx.min(x);
|
||||
miny = miny.min(y);
|
||||
maxx = maxx.max(x);
|
||||
maxy = maxy.max(y);
|
||||
}
|
||||
}
|
||||
if minx > maxx {
|
||||
return vec![frame.into_geometry()];
|
||||
}
|
||||
let pad = 10.0_f32;
|
||||
let w = (maxx - minx).max(1e-4);
|
||||
let h = (maxy - miny).max(1e-4);
|
||||
let s = ((bounds.width - 2.0 * pad) / w)
|
||||
.min((bounds.height - 2.0 * pad) / h)
|
||||
.max(0.0);
|
||||
let ox = pad + (bounds.width - 2.0 * pad - w * s) * 0.5;
|
||||
let oy = pad + (bounds.height - 2.0 * pad - h * s) * 0.5;
|
||||
// Flip Y: world up → screen down.
|
||||
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(ox + (x - minx) * s, bounds.height - (oy + (y - miny) * s))
|
||||
iced::Point::new(
|
||||
pad + (x - self.minx) * self.scale,
|
||||
self.content_h - (pad + (y - self.miny) * self.scale),
|
||||
)
|
||||
};
|
||||
for seg in &self.segments {
|
||||
if seg.len() < 2 {
|
||||
@@ -1557,6 +1550,11 @@ fn mtext_editor_overlay<'a>(
|
||||
button(lbl("1")).on_press(Message::MTextLineSpacing(1.0)).padding(3).style(btn_style),
|
||||
button(lbl("1.5")).on_press(Message::MTextLineSpacing(1.5)).padding(3).style(btn_style),
|
||||
button(lbl("2")).on_press(Message::MTextLineSpacing(2.0)).padding(3).style(btn_style),
|
||||
iced::widget::Space::new().width(6),
|
||||
button(lbl(if ed.show_preview { "Code" } else { "Preview" }))
|
||||
.on_press(Message::MTextTogglePreview)
|
||||
.padding(3)
|
||||
.style(btn_style),
|
||||
iced::widget::Space::new().width(Fill),
|
||||
icon_btn(include_bytes!("../../assets/icons/mt_ok.svg"), Message::MTextOk),
|
||||
icon_btn(include_bytes!("../../assets/icons/mt_cancel.svg"), Message::MTextCancel),
|
||||
@@ -1564,28 +1562,49 @@ fn mtext_editor_overlay<'a>(
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Dedicated preview area (rendered MText) ──────────────────────────
|
||||
let preview = container(
|
||||
canvas(MTextPreview { segments: mtext_preview_segments(ed) })
|
||||
// ── Body: toggles between raw code input and rendered preview ────────
|
||||
const VIEW_H: f32 = 150.0;
|
||||
let body: Element<'a, Message> = if ed.show_preview {
|
||||
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);
|
||||
}
|
||||
}
|
||||
let h_unit = ed.height_value() as f32;
|
||||
// Real text size: fixed pixels per em so more/taller text grows the
|
||||
// canvas (and scrolls) instead of shrinking to fit.
|
||||
let scale = (22.0 / h_unit.max(1e-3)).clamp(2.0, 600.0);
|
||||
let content_h = if maxx >= minx {
|
||||
((maxy - miny) * scale + 2.0 * MTEXT_PREVIEW_PAD).max(40.0)
|
||||
} else {
|
||||
40.0
|
||||
};
|
||||
let prog = MTextPreview { segments, minx, miny, scale, content_h };
|
||||
let cv = canvas(prog).width(Fill).height(iced::Length::Fixed(content_h));
|
||||
container(iced::widget::scrollable(cv).height(iced::Length::Fixed(VIEW_H)))
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(FIELD_BG)),
|
||||
border: Border { color: BORDER, width: 1.0, radius: 3.0.into() },
|
||||
..Default::default()
|
||||
})
|
||||
.padding(2)
|
||||
.width(Fill)
|
||||
.height(iced::Length::Fixed(96.0)),
|
||||
)
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(FIELD_BG)),
|
||||
border: Border { color: BORDER, width: 1.0, radius: 3.0.into() },
|
||||
..Default::default()
|
||||
})
|
||||
.padding(2)
|
||||
.width(Fill);
|
||||
.into()
|
||||
} else {
|
||||
text_editor(&ed.content)
|
||||
.on_action(Message::MTextEdit)
|
||||
.height(iced::Length::Fixed(VIEW_H))
|
||||
.padding(6)
|
||||
.size(13)
|
||||
.into()
|
||||
};
|
||||
|
||||
// ── Raw text + inline-code input ─────────────────────────────────────
|
||||
let editor = text_editor(&ed.content)
|
||||
.on_action(Message::MTextEdit)
|
||||
.height(iced::Length::Fixed(72.0))
|
||||
.padding(6)
|
||||
.size(13);
|
||||
|
||||
let panel = container(column![row1, row2, preview, editor].spacing(5))
|
||||
let panel = container(column![row1, row2, body].spacing(5))
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(PANEL_BG)),
|
||||
border: Border { color: BORDER, width: 1.0, radius: 5.0.into() },
|
||||
|
||||
Reference in new issue
Block a user