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 Seven 2026-05-31 02:33:47 +03:00
commit 32136dd9dd
9 changed files with 443 additions and 31 deletions

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 {