feat(mtext): character-level text selection on the preview

The rendered MText preview is now interactive: drag across glyphs to select
a character range and apply toolbar formatting (bold, italic, underline,
overline, strike, case, alignment) to exactly those characters.

The text layout gains an opt-in pass that emits one world-space box per
visible character carrying its reading-order index; the editor maps those
boxes into the preview frame, hit-tests pointer drags against them, and
highlights the selection. Formatting splices the matching inline codes into
the raw value at the selected byte range (mapped via a code-aware visible-
character scanner). With no preview selection the Edit-box path is unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-05-31 02:33:47 +03:00
1 parent a07af32714
commit 32136dd9dd
9 files changed
+443 -31

No files matched your search

+1
View File
@@ -208,6 +208,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument)
v_anchor,
line_spacing_factor: 1.0,
vertical_text: false,
want_glyph_boxes: false,
});
let _ = input.line_count;
let _ = input.is_multiline;
+48 -1
View File
@@ -4,7 +4,7 @@ use glam::Vec3;
use crate::command::EntityTransform;
use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip, triangle_grip};
use crate::entities::text_support::{
layout_mtext, resolve_text_style, MTextRenderOpts, MTextVAnchor,
layout_mtext, resolve_text_style, GlyphBox, MTextRenderOpts, MTextVAnchor,
};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
use crate::scene::acad_to_truck::{TruckEntity, TruckObject};
@@ -74,6 +74,52 @@ fn drawing_dir_str(d: &DrawingDirection) -> &'static str {
}
}
/// Per-visible-character world-space boxes for the MText editor's
/// click-to-select preview. Uses the exact same layout opts as `to_truck`
/// so the boxes line up with the rendered glyphs.
pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec<GlyphBox> {
let resolved_style = resolve_text_style(&t.style, document);
let attach_h_anchor: f32 = match t.attachment_point {
AttachmentPoint::TopCenter
| AttachmentPoint::MiddleCenter
| AttachmentPoint::BottomCenter => 0.5,
AttachmentPoint::TopRight | AttachmentPoint::MiddleRight | AttachmentPoint::BottomRight => {
1.0
}
_ => 0.0,
};
let v_anchor = match t.attachment_point {
AttachmentPoint::TopLeft | AttachmentPoint::TopCenter | AttachmentPoint::TopRight => {
MTextVAnchor::Top
}
AttachmentPoint::MiddleLeft
| AttachmentPoint::MiddleCenter
| AttachmentPoint::MiddleRight => MTextVAnchor::Middle,
AttachmentPoint::BottomLeft
| AttachmentPoint::BottomCenter
| AttachmentPoint::BottomRight => MTextVAnchor::Bottom,
};
let rotation = if resolved_style.is_upside_down {
t.rotation as f32 + std::f32::consts::PI
} else {
t.rotation as f32
};
let layout = layout_mtext(&MTextRenderOpts {
value: &t.value,
insertion: [t.insertion_point.x, t.insertion_point.y, t.insertion_point.z],
height: t.height as f32,
rect_w: t.rectangle_width as f32,
rotation,
style: &resolved_style,
attach_h_anchor,
v_anchor,
line_spacing_factor: t.line_spacing_factor as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
want_glyph_boxes: true,
});
layout.glyph_boxes
}
fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
let resolved_style = resolve_text_style(&t.style, document);
let attach_h_anchor: f32 = match t.attachment_point {
@@ -112,6 +158,7 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
v_anchor,
line_spacing_factor: t.line_spacing_factor as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
want_glyph_boxes: false,
});
let insertion = Vec3::new(
t.insertion_point.x as f32,
+2
View File
@@ -257,6 +257,7 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckE
v_anchor,
line_spacing_factor: ctx.line_spacing_factor as f32,
vertical_text: false,
want_glyph_boxes: false,
});
for ts in &layout.strokes {
let ox = ts.origin[0];
@@ -1312,6 +1313,7 @@ impl MultiLeaderTess for MultiLeader {
v_anchor,
line_spacing_factor: ctx.line_spacing_factor as f32,
vertical_text: false,
want_glyph_boxes: false,
});
let line_widths = &layout.line_widths;
let max_line_w = line_widths.iter().cloned().fold(0.0_f32, f32::max);
+1
View File
@@ -267,6 +267,7 @@ impl TruckConvertible for Table {
v_anchor,
line_spacing_factor: 1.0,
vertical_text: false,
want_glyph_boxes: false,
});
// Flatten TextStroke groups into the table's Lines buffer.
// Per-run inline `\C` / `\c` colour is dropped here because the
+74 -2
View File
@@ -1067,6 +1067,22 @@ pub struct MTextRenderOpts<'a> {
pub line_spacing_factor: f32,
/// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2).
pub vertical_text: bool,
/// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with
/// one world-space box per visible character (used by the MText editor's
/// click-to-select preview). Off in the hot render path.
pub want_glyph_boxes: bool,
}
/// One selectable character in the laid-out text: its world-space AABB plus
/// the running index of visible characters (in reading order) so the editor
/// can map a clicked box back to an offset in the value.
#[derive(Clone, Copy, Debug)]
pub struct GlyphBox {
pub vis: usize,
pub xmin: f32,
pub xmax: f32,
pub ymin: f32,
pub ymax: f32,
}
/// Output of [`layout_mtext`]: stroke groups + the geometry the caller
@@ -1088,6 +1104,9 @@ pub struct MTextLayout {
/// Y of the first sub-line's baseline relative to the insertion point
/// (in the entity-local, pre-rotation frame).
pub v_offset: f32,
/// One world-space AABB per visible character — only populated when
/// `MTextRenderOpts::want_glyph_boxes` is set.
pub glyph_boxes: Vec<GlyphBox>,
}
/// Lay out and render an MText-formatted value, returning the stroke
@@ -1232,6 +1251,18 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
// ── 4. Render each sub-line ──────────────────────────────────────────
let mut all_strokes: Vec<TextStroke> = Vec::new();
let mut line_widths: Vec<f32> = Vec::with_capacity(sub_lines.len());
let mut glyph_boxes: Vec<GlyphBox> = Vec::new();
let mut vis: usize = 0;
// Transform an entity-local point to world space (mirrors the stroke
// origin maths) so glyph boxes line up with the drawn glyphs.
let to_world = |line_base_x: f32, line_base_y: f32, lx: f32, ly: f32| -> (f32, f32) {
let wdx = lx * cos_r - ly * sin_r;
let wdy = lx * sin_r + ly * cos_r;
(
ins_x as f32 + line_base_x + wdx,
ins_y as f32 + line_base_y + wdy,
)
};
for (i, sub) in sub_lines.iter().enumerate() {
let li = i as f32;
let (line_base_x, line_base_y) = if opts.vertical_text {
@@ -1337,12 +1368,52 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
origin,
color,
});
if opts.want_glyph_boxes {
// Per-character boxes, advancing exactly as
// `measure_word` does so they track the glyphs.
let scale = run_scale(&atom.state, entity_h, base_wf);
let font = cxf::get_font(font_name);
let mut cx = cursor_x;
for ch in text.chars() {
let adv = match font.glyph(ch) {
Some(g) => {
(g.advance + font.letter_spacing * tracking) * scale
}
None => (6.0 + font.letter_spacing * tracking) * scale,
};
let (ax, ay) = to_world(line_base_x, line_base_y, cx, ly);
let (bx, by) = to_world(line_base_x, line_base_y, cx + adv, ly + run_h);
glyph_boxes.push(GlyphBox {
vis,
xmin: ax.min(bx),
xmax: ax.max(bx),
ymin: ay.min(by),
ymax: ay.max(by),
});
vis += 1;
cx += adv;
}
}
cursor_x +=
measure_word(text, &atom.state, entity_h, base_wf, &base_font_name);
}
AtomKind::Space => {
cursor_x +=
measure_space(&atom.state, entity_h, base_wf, &base_font_name);
let adv = measure_space(&atom.state, entity_h, base_wf, &base_font_name);
if opts.want_glyph_boxes {
let run_h = atom.state.height_mul * entity_h;
let (ax, ay) = to_world(line_base_x, line_base_y, cursor_x, 0.0);
let (bx, by) =
to_world(line_base_x, line_base_y, cursor_x + adv, run_h);
glyph_boxes.push(GlyphBox {
vis,
xmin: ax.min(bx),
xmax: ax.max(bx),
ymin: ay.min(by),
ymax: ay.max(by),
});
vis += 1;
}
cursor_x += adv;
}
AtomKind::Tab => {
cursor_x = next_tab_position(
@@ -1362,6 +1433,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
line_count: sub_lines.len(),
line_height: line_h,
v_offset,
glyph_boxes,
}
}
pub(crate) fn text_obb_corners_native(