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 <noreply@anthropic.com>
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

+4
View File
@@ -740,6 +740,10 @@ pub enum Message {
/// Switch the editor body between raw code input (`false`) and the
/// rendered preview (`true`).
MTextShowPreview(bool),
/// Begin a preview text selection at the given visible-character offset.
MTextSelStart(usize),
/// Extend the preview selection to the given visible-character offset.
MTextSelTo(usize),
/// Commit the editor: create or update the MText entity.
MTextOk,
/// Discard the editor without creating / changing the entity.
+180 -22
View File
@@ -114,6 +114,13 @@ pub struct MTextEditorState {
/// 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,
/// Per-visible-character boxes (world XY, world_offset already removed) for
/// click-to-select in the preview, and the current selection as a visible-
/// character range `[start, end)`.
pub glyph_boxes: Vec<crate::entities::text_support::GlyphBox>,
pub sel: Option<(usize, usize)>,
/// Anchor offset for an in-progress drag selection.
pub sel_anchor: usize,
/// Canvas-space anchor where the toolbar + text area are drawn (the
/// insertion-point click position).
pub screen_anchor: iced::Point,
@@ -136,6 +143,9 @@ impl MTextEditorState {
char_space: "0".to_string(),
preview_wires: Vec::new(),
show_preview: true,
glyph_boxes: Vec::new(),
sel: None,
sel_anchor: 0,
attachment: AttachmentPoint::TopLeft,
line_spacing: 1.0,
// Default box ~20 characters wide; overwritten with the entity's
@@ -191,9 +201,6 @@ impl MTextEditorState {
}
}
pub fn build_entity(&self) -> EntityType {
EntityType::MText(self.build_mtext())
}
}
/// Parse a numeric field, returning `Some(v)` only when it differs from the
@@ -207,6 +214,97 @@ fn parse_non_default(s: &str, default: f64) -> Option<f64> {
}
}
/// Map each visible character of a raw MText value to its byte span
/// `(start, end)` in that raw string, in the same reading order the layout
/// counts glyph boxes (paragraphs split on `\P`/`\n`/`\N`, leading/trailing
/// spaces trimmed per paragraph, inline codes skipped). Lets a preview
/// selection (visible-char range) be spliced back into the raw value.
pub fn visible_spans(raw: &str) -> Vec<(usize, usize)> {
let is_sp = |c: char| c == ' ' || c == '\u{00A0}';
let mut result: Vec<(usize, usize)> = Vec::new();
let mut para: Vec<(usize, usize, char)> = Vec::new();
let mut flush = |para: &mut Vec<(usize, usize, char)>, result: &mut Vec<(usize, usize)>| {
let s = para.iter().position(|t| !is_sp(t.2)).unwrap_or(para.len());
let e = para.iter().rposition(|t| !is_sp(t.2)).map(|i| i + 1).unwrap_or(s);
for t in &para[s..e] {
result.push((t.0, t.1));
}
para.clear();
};
let mut it = raw.char_indices().peekable();
while let Some((i, ch)) = it.next() {
match ch {
'\\' => match it.peek().map(|&(_, c)| c) {
Some('P') | Some('n') | Some('N') => {
it.next();
flush(&mut para, &mut result);
}
Some('~') => {
let (j, c) = it.next().unwrap();
para.push((i, j + c.len_utf8(), '\u{00A0}'));
}
Some('\\') | Some('{') | Some('}') => {
let (j, c) = it.next().unwrap();
para.push((i, j + c.len_utf8(), c));
}
Some(c) if "LlOoKk".contains(c) => {
it.next(); // value-less toggle, no visible glyph
}
Some(_) => {
// Value code (\f… \C… \H… \pxq… \U… etc) — skip to ';'.
it.next();
while let Some(&(_, c)) = it.peek() {
it.next();
if c == ';' {
break;
}
}
}
None => {}
},
'{' | '}' => { /* group markers — not visible */ }
'%' if it.peek().map(|&(_, c)| c) == Some('%') => {
it.next(); // second '%'
match it.peek().copied() {
Some((k, '%')) => {
it.next();
para.push((i, k + 1, '%'));
}
Some((_, d)) if d.is_ascii_digit() => {
let mut last = i;
let mut n = 0;
while n < 3 {
match it.peek().copied() {
Some((m, c)) if c.is_ascii_digit() => {
last = m;
it.next();
n += 1;
}
_ => break,
}
}
para.push((i, last + 1, '\u{25A1}'));
}
Some((m, c)) => {
it.next();
let g = match c {
'd' | 'D' => '°',
'c' | 'C' => 'Ø',
'p' | 'P' => '±',
other => other,
};
para.push((i, m + c.len_utf8(), g));
}
None => para.push((i, i + 1, '%')),
}
}
_ => para.push((i, i + ch.len_utf8(), ch)),
}
}
flush(&mut para, &mut result);
result
}
/// Prefix/suffix inline codes for a character-level toggle. `Uppercase` /
/// `Lowercase` return `None` (they transform the selected text instead).
pub fn fmt_wrap(kind: MTextFmt) -> Option<(&'static str, &'static str)> {
@@ -266,7 +364,8 @@ impl super::OpenCADStudio {
pub(super) fn rebuild_mtext_preview(&mut self) {
let i = self.active_tab;
let Some(ed) = self.mtext_editor.as_ref() else { return };
let entity = ed.build_entity();
let mt = ed.build_mtext();
let entity = EntityType::MText(mt.clone());
let woff = self.tabs[i].scene.world_offset;
let anno = self.tabs[i].scene.annotation_scale;
let wires: Vec<WireModel> = tessellate::tessellate(
@@ -281,35 +380,94 @@ impl super::OpenCADStudio {
woff,
anno,
);
// Per-character boxes in the same offset-subtracted frame as the
// preview wires (tessellate removes world_offset; mirror that).
let mut boxes = crate::entities::mtext::glyph_boxes(&mt, &self.tabs[i].scene.document);
let (ox, oy) = (woff[0] as f32, woff[1] as f32);
for b in &mut boxes {
b.xmin -= ox;
b.xmax -= ox;
b.ymin -= oy;
b.ymax -= oy;
}
if let Some(ed) = self.mtext_editor.as_mut() {
ed.preview_wires = wires;
ed.glyph_boxes = boxes;
}
}
/// Apply a character-format toggle to the current selection (or insert at
/// the cursor when there is no selection).
/// Splice text around the preview selection (visible-char range) in the
/// raw value. `case` optionally transforms the selected slice. Returns
/// true when a preview selection was present and applied.
fn mtext_splice_sel(&mut self, prefix: &str, suffix: &str, case: Option<bool>) -> bool {
let Some(ed) = self.mtext_editor.as_mut() else { return false };
let Some((a, b)) = ed.sel else { return false };
if a >= b {
return false;
}
let raw = ed.content.text();
let raw = raw.strip_suffix('\n').unwrap_or(&raw).to_string();
let spans = visible_spans(&raw);
if a >= spans.len() || b > spans.len() {
return false;
}
let start = spans[a].0;
let end = spans[b - 1].1;
let mut s = raw;
if let Some(upper) = case {
let slice = &s[start..end];
let repl = if upper { slice.to_uppercase() } else { slice.to_lowercase() };
s.replace_range(start..end, &repl);
// Length may change; recompute end for the suffix insert.
let new_end = start + repl.len();
s.insert_str(new_end, suffix);
s.insert_str(start, prefix);
} else {
s.insert_str(end, suffix);
s.insert_str(start, prefix);
}
ed.content = iced::widget::text_editor::Content::with_text(&s);
ed.sel = None;
true
}
/// Apply a character-format toggle to the preview selection (preferred) or
/// the Edit-box selection.
pub(super) fn mtext_apply_fmt(&mut self, kind: MTextFmt) {
if let Some(ed) = self.mtext_editor.as_mut() {
let sel = ed.content.selection();
let text = match kind {
MTextFmt::Uppercase => sel.as_deref().unwrap_or("").to_uppercase(),
MTextFmt::Lowercase => sel.as_deref().unwrap_or("").to_lowercase(),
_ => {
let (pre, suf) = fmt_wrap(kind).unwrap();
format!("{pre}{}{suf}", sel.as_deref().unwrap_or(""))
}
};
ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text))));
let applied = match kind {
MTextFmt::Uppercase => self.mtext_splice_sel("", "", Some(true)),
MTextFmt::Lowercase => self.mtext_splice_sel("", "", Some(false)),
_ => {
let (pre, suf) = fmt_wrap(kind).unwrap();
self.mtext_splice_sel(pre, suf, None)
}
};
if !applied {
if let Some(ed) = self.mtext_editor.as_mut() {
let sel = ed.content.selection();
let text = match kind {
MTextFmt::Uppercase => sel.as_deref().unwrap_or("").to_uppercase(),
MTextFmt::Lowercase => sel.as_deref().unwrap_or("").to_lowercase(),
_ => {
let (pre, suf) = fmt_wrap(kind).unwrap();
format!("{pre}{}{suf}", sel.as_deref().unwrap_or(""))
}
};
ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text))));
}
}
self.rebuild_mtext_preview();
}
/// Prefix the selection with a paragraph-alignment code.
/// Prefix the selection (or cursor) with a paragraph-alignment code.
pub(super) fn mtext_apply_align(&mut self, align: ParaAlign) {
if let Some(ed) = self.mtext_editor.as_mut() {
let sel = ed.content.selection().unwrap_or_default();
let text = format!("\\pxq{};{sel}", align.code());
ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text))));
let code = format!("\\pxq{};", align.code());
if !self.mtext_splice_sel(&code, "", None) {
if let Some(ed) = self.mtext_editor.as_mut() {
let sel = ed.content.selection().unwrap_or_default();
let text = format!("{code}{sel}");
ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text))));
}
}
self.rebuild_mtext_preview();
}
+14
View File
@@ -3546,6 +3546,20 @@ impl OpenCADStudio {
self.rebuild_mtext_preview();
Task::none()
}
Message::MTextSelStart(off) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.sel_anchor = off;
ed.sel = Some((off, off));
}
Task::none()
}
Message::MTextSelTo(off) => {
if let Some(ed) = self.mtext_editor.as_mut() {
let a = ed.sel_anchor;
ed.sel = Some((a.min(off), a.max(off)));
}
Task::none()
}
Message::MTextOk => {
self.mtext_commit();
Task::none()
+119 -6
View File
@@ -1346,13 +1346,14 @@ const MTEXT_COLORS: [(&str, u16); 8] = [
/// 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)>>,
/// 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)>,
/// World-space min corner (bbox) and pixels-per-world-unit scale.
minx: f32,
miny: f32,
@@ -1360,11 +1361,93 @@ struct MTextPreview {
content_h: 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 = ();
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: &(),
_state: &MTextPreviewState,
renderer: &iced::Renderer,
_theme: &Theme,
bounds: iced::Rectangle,
@@ -1380,6 +1463,20 @@ impl iced::widget::canvas::Program<Message> for MTextPreview {
self.content_h - (pad + (y - self.miny) * self.scale),
)
};
// 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, Color { r: 0.20, g: 0.42, b: 0.72, a: 0.45 });
}
}
}
for seg in &self.segments {
if seg.len() < 2 {
continue;
@@ -1601,6 +1698,14 @@ fn mtext_editor_overlay<'a>(
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 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.
@@ -1610,7 +1715,15 @@ fn mtext_editor_overlay<'a>(
} else {
40.0
};
let prog = MTextPreview { segments, minx, miny, scale, content_h };
let prog = MTextPreview {
segments,
boxes: ed.glyph_boxes.clone(),
sel: ed.sel,
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 {
+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(