Files
vectorcraft/crates/tools/src/text.rs
T
imachusandClaude Opus 5.5 66fa0a998a Type: input method composition (Japanese IME) in the Type tool
Typing with an input method (the macOS Japanese IME and the like) used to
insert the raw keys (romaji came in as ASCII): egui's Ime events weren't
handled.

- Tools gain `ime_preedit` / `composing` / `ime_caret`; the Type tool lays
  the marked text out in place inside its typing session, underlined (the
  clause being converted thicker), and commits it through `text_input`.
  Character ranges from the IME convert to byte ranges in one place. A
  cancelled composition leaves no empty undo step; an empty clear with
  nothing marked never deletes the selection.
- The UI sends Text and Ime events to the tool in order, sends IMEOutput
  every frame while text is edited (what lets egui-winit enable the IME
  and place its candidate window at the caret, or the converting clause),
  and holds back keys, Enter/Escape, clipboard, single-key shortcuts and
  menu commands (Undo, Cut, Select All… also from the native menu) while
  composing. A click, a tool switch or a style change ends the
  composition with the marked text kept as typed; on macOS the IME is
  told to discard it (NSTextInputContext.discardMarkedText) so it doesn't
  come back in the next input.
- Undo during a typing session also ends the Type tool's session, so its
  caret and marked text follow the document.
- Tests: tools (char → byte ranges, underlines and candidate position),
  engine (compose → commit is one step, cancel leaves no step, a click
  commits, Undo mid-composition is safe), ui-egui (synthetic Ime events:
  compose, commit, ⌘Z held back, IME output, click and app switch).

Checked by hand on macOS 27 with the Japanese IME (romaji input, convert,
commit, cancel, Undo/Redo after commit, click away, Latin mode, app
switch). Not checked: Windows and Linux IMEs, the web build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:41:50 +09:00

708 lines
29 KiB
Rust

//! The Type tools: Type (T), Area Type and Type on a Path.
//!
//! Type: click places point type, drag draws an area-type frame, clicking into existing text
//! places the caret. Area Type / Type on a Path: click a path to turn it into a text frame or a
//! baseline (`text.createInPath`). Point type placed by a click and left empty is discarded when
//! editing ends (`text.discardEmpty`); frames keep their shape.
//!
//! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends
//! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a
//! word and triple-click a paragraph, typing replaces the selection. Edits are coalesced into one
//! undo step per typing session: the tool keeps the styled runs locally and previews a single
//! `text.editRange` (the changed span, with its styled runs) against the session snapshot.
//! Styling a selected range goes through `text.setRangeStyle` (the Character panel reads the
//! selection from [`Tool::options`]).
//!
//! IME: the marked text of a composition is part of the typing session (so it lays out in place),
//! underlined, until the IME commits it (it then goes through [`Tool::text_input`]) or clears it.
use std::ops::Range;
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_doc::hit::hit_test;
use vectorcraft_doc::{NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::{BezPath, Point, Rect, Shape};
use vectorcraft_text::{FontDb, TextLayout, edit};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// Which of the Type tools this is.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum Mode {
#[default]
Type,
Area,
OnPath,
}
/// A coalesced typing session: the runs at its start and the runs now.
struct Typing {
base: Vec<TextRun>,
cur: Vec<TextRun>,
}
/// IME marked text inside the typing session, not yet committed.
#[derive(Clone, Debug, PartialEq)]
struct Preedit {
/// Byte range of the marked text in the session's runs.
range: Range<usize>,
/// The clause being converted, in bytes relative to `range.start` (empty: the IME's cursor).
active: Option<Range<usize>>,
}
#[derive(Default)]
pub struct TypeTool {
vertical: bool,
mode: Mode,
/// Text object being edited.
editing: Option<NodeId>,
/// The point type a click just placed (edited now): discarded if empty when editing ends.
fresh: Option<NodeId>,
/// Caret and selection anchor (byte offsets into the plain text).
caret: usize,
anchor: usize,
/// Horizontal position kept by Up/Down (text space).
goal_x: Option<f64>,
typing: Option<Typing>,
preedit: Option<Preedit>,
press: Option<Point>,
drag: Option<Point>,
/// The press landed in the edited text: dragging selects.
selecting: bool,
/// Click counting for double/triple click (position of the last click, count).
clicks: (Option<Point>, u8),
/// Styled copy of the last copied range (pasting the same plain text keeps its styles).
clipboard: Vec<TextRun>,
cache: Mutex<Option<(TextObject, Arc<TextLayout>)>>,
}
impl TypeTool {
pub fn new(id: &str) -> Self {
let mode = match id {
"areaType" | "verticalAreaType" => Mode::Area,
"typeOnPath" | "verticalTypeOnPath" => Mode::OnPath,
_ => Mode::Type,
};
Self { mode, vertical: id.starts_with("vertical"), ..Default::default() }
}
fn text<'a>(cx: &'a ToolContext, id: NodeId) -> Option<&'a TextObject> {
match &cx.doc.node(id)?.kind {
NodeKind::Text(t) => Some(t),
_ => None,
}
}
/// Layout of `t`, cached by content (overlays run every frame).
fn layout(&self, t: &TextObject) -> Arc<TextLayout> {
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some((k, l)) = c.as_ref()
&& k == t
{
return l.clone();
}
let l = Arc::new(vectorcraft_text::layout(FontDb::global(), t));
*c = Some((t.clone(), l.clone()));
l
}
fn plain(&self, cx: &ToolContext) -> String {
match &self.typing {
Some(ty) => ty.cur.iter().map(|r| r.text.as_str()).collect(),
None => self.editing.and_then(|id| Self::text(cx, id)).map(|t| t.plain_text()).unwrap_or_default(),
}
}
/// The edited object as it currently is (with the typing session's runs).
fn current(&self, cx: &ToolContext) -> Option<TextObject> {
let mut t = Self::text(cx, self.editing?)?.clone();
if let Some(ty) = &self.typing {
t.runs = ty.cur.clone();
}
Some(t)
}
fn sel(&self) -> (usize, usize) {
(self.caret.min(self.anchor), self.caret.max(self.anchor))
}
/// End the typing session (one undo step). Marked text left by an IME stays as typed (as
/// clicking away from a composition does in macOS text views); the UI interrupts the IME.
fn commit(&mut self) -> Vec<Action> {
self.preedit = None;
if self.typing.take().is_some() { vec![Action::Commit] } else { vec![] }
}
/// Stop editing. Point type a click placed that is still empty is discarded, with the steps
/// since its click (`text.discardEmpty`).
fn finish(&mut self, cx: &ToolContext) -> Vec<Action> {
let mut out = self.commit();
if let Some(id) = self.fresh.take()
&& self.editing == Some(id)
&& Self::text(cx, id).is_some_and(|t| matches!(t.kind, TextKind::Point) && edit::runs_len(&t.runs) == 0)
{
out.push(Action::Exec("text.discardEmpty".into(), json!({"id": id.0})));
}
self.editing = None;
self.selecting = false;
self.goal_x = None;
out
}
/// Replace the selection with `insert` (styled `runs` when given) inside the typing session.
fn replace(&mut self, cx: &ToolContext, insert: &str, styled: Option<Vec<TextRun>>) -> Vec<Action> {
let Some(id) = self.editing else { return vec![] };
let Some(t) = Self::text(cx, id) else {
self.editing = None;
return vec![];
};
let mut out = vec![];
// The document changed under us (undo, a panel edit): start a new session.
if self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs) {
out.push(Action::Commit);
self.typing = None;
}
if self.typing.is_none() {
out.push(Action::Begin("Typing".into()));
}
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone() });
let len = edit::runs_len(&ty.cur);
let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len));
let caret = match &styled {
Some(r) => edit::replace_range_styled(&mut ty.cur, a, b, r),
None => edit::replace_range(&mut ty.cur, a, b, insert),
};
self.caret = caret;
self.anchor = caret;
self.goal_x = None;
// One preview for the whole session: the changed span of base → cur, with its runs.
let base: String = ty.base.iter().map(|r| r.text.as_str()).collect();
let cur: String = ty.cur.iter().map(|r| r.text.as_str()).collect();
let (p, s) = common_affixes(&base, &cur);
let runs = edit::slice_runs(&ty.cur, p, cur.len() - s);
let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) };
out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs})));
out
}
/// Move the caret (Shift keeps the anchor). Moving ends the typing session.
fn move_to(&mut self, byte: usize, extend: bool) -> Vec<Action> {
let out = self.commit();
self.caret = byte;
if !extend {
self.anchor = byte;
}
out
}
fn start_editing(&mut self, id: NodeId, caret: usize) {
self.editing = Some(id);
self.fresh = None;
self.caret = caret;
self.anchor = caret;
self.goal_x = None;
self.typing = None;
self.preedit = None;
}
/// Byte under `p` in the edited text if `p` is inside it (frame, path band or layout bounds).
fn hit_edited(&self, cx: &ToolContext, p: Point) -> Option<usize> {
let t = self.current(cx)?;
let lay = self.layout(&t);
let local = t.xf.inverse() * p;
let tol = cx.tol(4.0);
let inside = match &t.kind {
TextKind::Area { frame } => frame.bounds().is_some_and(|b| b.inflate(tol, tol).contains(local)),
_ => lay.bounds.inflate(tol, tol).contains(local),
};
inside.then(|| vectorcraft_text::hit_byte(&lay, local))
}
fn select_word_or_para(&mut self, cx: &ToolContext, para: bool) {
let s = self.plain(cx);
let r = if para { edit::paragraph_at(&s, self.caret) } else { edit::word_at(&s, self.caret) };
self.anchor = r.start;
self.caret = r.end;
}
/// Path under `p` for the Area Type / Type on a Path tools.
fn path_at(cx: &ToolContext, p: Point, closed: bool) -> Option<NodeId> {
let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { tol: cx.tol(4.0), outline: true, path_only: false })?;
match &cx.doc.node(h.leaf)?.kind {
NodeKind::Path { path, .. } if !closed || path.is_closed() => Some(h.leaf),
_ => None,
}
}
fn on_up(&mut self, cx: &ToolContext, start: Point) -> Vec<Action> {
let mut out = self.finish(cx);
// Click into existing text: place the caret.
if let Some(h) = hit_test(cx.doc, start, cx.hit_options())
&& let Some(NodeKind::Text(t)) = cx.doc.node(h.leaf).map(|n| &n.kind)
{
let lay = self.layout(t);
let byte = vectorcraft_text::hit_byte(&lay, t.xf.inverse() * start);
self.start_editing(h.leaf, byte);
self.clicks = (Some(start), 1);
out.push(Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]})));
return out;
}
let drag = self.drag.take();
// Area Type / Type on a Path: click a path.
if self.mode != Mode::Type && drag.is_none() {
let on_path = self.mode == Mode::OnPath;
if let Some(pid) = Self::path_at(cx, start, !on_path) {
let mode = if on_path { "onPath" } else { "area" };
out.push(Action::Exec(
"text.createInPath".into(),
json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical}),
));
out.push(Action::Notify("text.editNew".into()));
return out;
}
}
let area = drag.map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
let mut params = match area {
Some(r) => json!({"x": r.x0, "y": r.y0 + 12.0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
None => json!({"x": start.x, "y": start.y, "text": ""}),
};
params["vertical"] = json!(self.vertical);
out.push(Action::Exec("text.create".into(), params));
out.push(Action::Notify("text.editNew".into()));
out
}
fn overflow_marker(t: &TextObject, lay: &TextLayout, cx: &ToolContext, o: &mut Vec<Overlay>) {
if !lay.overflow {
return;
}
// The out port: bottom-right of the frame (end of the path for type on a path).
let port = match &t.kind {
TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)),
TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)),
TextKind::Point => None,
};
let Some(c) = port else { return };
let h = cx.tol(4.5);
let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h);
let red = [230, 30, 30];
o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false });
let (k, m) = (cx.tol(2.5), r.center());
o.push(Overlay::Line { a: Point::new(m.x - k, m.y), b: Point::new(m.x + k, m.y), color: red, dashed: false });
o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false });
}
}
/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes).
/// `None` when `r` runs past the end of `s` or backwards.
fn char_range_to_bytes(s: &str, r: Range<usize>) -> Option<Range<usize>> {
let byte = |c: usize| if c == s.chars().count() { Some(s.len()) } else { s.char_indices().nth(c).map(|(i, _)| i) };
let (a, b) = (byte(r.start)?, byte(r.end)?);
(a <= b).then_some(a..b)
}
/// Lengths of the common prefix and suffix of `a` and `b` (on char boundaries, not overlapping).
fn common_affixes(a: &str, b: &str) -> (usize, usize) {
let mut p = 0;
for ((i, x), y) in a.char_indices().zip(b.chars()) {
if x != y {
break;
}
p = i + x.len_utf8();
}
let max_s = (a.len() - p).min(b.len() - p);
let mut s = 0;
for (x, y) in a[p..].chars().rev().zip(b[p..].chars().rev()) {
if x != y || s + x.len_utf8() > max_s {
break;
}
s += x.len_utf8();
}
(p, s)
}
impl Tool for TypeTool {
fn id(&self) -> &'static str {
match (self.mode, self.vertical) {
(Mode::Type, false) => "type",
(Mode::Type, true) => "verticalType",
(Mode::Area, false) => "areaType",
(Mode::Area, true) => "verticalAreaType",
(Mode::OnPath, false) => "typeOnPath",
(Mode::OnPath, true) => "verticalTypeOnPath",
}
}
fn busy(&self) -> bool {
self.press.is_some() || self.selecting
}
fn wants_text(&self) -> bool {
self.editing.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down => {
if let Some(byte) = self.hit_edited(cx, ev.pos) {
let same = self.clicks.0.is_some_and(|q| (q - ev.pos).hypot() <= cx.tol(4.0));
let n = if same { self.clicks.1 + 1 } else { 1 };
self.clicks = (Some(ev.pos), n);
let out = self.move_to(byte, ev.mods.shift);
self.goal_x = None;
if n >= 3 {
self.select_word_or_para(cx, true);
self.clicks.1 = 0;
} else {
self.selecting = true;
}
return out;
}
self.clicks = (None, 0);
self.press = Some(ev.pos);
self.drag = None;
vec![]
}
PointerKind::Drag => {
if self.selecting {
if let Some(t) = self.current(cx) {
let lay = self.layout(&t);
self.caret = vectorcraft_text::hit_byte(&lay, t.xf.inverse() * ev.pos);
self.clicks = (None, 0);
}
} else if self.press.is_some() {
self.drag = Some(ev.pos);
}
vec![]
}
PointerKind::Up => {
if self.selecting {
self.selecting = false;
return vec![];
}
let Some(start) = self.press.take() else { return vec![] };
self.on_up(cx, start)
}
PointerKind::DoubleClick => {
if self.editing.is_some() && self.hit_edited(cx, ev.pos).is_some() {
self.select_word_or_para(cx, false);
self.clicks = (Some(ev.pos), 2);
}
vec![]
}
PointerKind::Move => vec![],
}
}
fn text_input(&mut self, cx: &ToolContext, s: &str) -> Vec<Action> {
if self.editing.is_none() {
return vec![];
}
let s: String = s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect();
// An IME commit replaces its marked text (an empty commit just removes it).
let committing = self.preedit.is_some();
if committing && s.is_empty() {
return self.ime_preedit(cx, "", None);
}
if let Some(p) = self.preedit.take() {
self.anchor = p.range.start;
self.caret = p.range.end;
}
if s.is_empty() {
return vec![];
}
// Use Typographer's Quotes (Document Setup): a typed straight quote becomes the document's
// opening or closing quote, by the character before it.
let setup = &cx.doc.setup;
let s = if setup.typographers_quotes && matches!(s.as_str(), "\"" | "'") {
let text = self.plain(cx);
let mut prev = text.get(..self.caret.min(self.anchor)).and_then(|b| b.chars().next_back());
setup.quotes.apply(&s, &mut prev)
} else {
s
};
self.clicks = (None, 0);
// Pasting what we copied keeps its formatting (unless pasted text is kept plain).
let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect();
if !committing && s.len() > 1 && s == clip && !cx.paste_plain_text {
let runs = self.clipboard.clone();
return self.replace(cx, "", Some(runs));
}
self.replace(cx, &s, None)
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
// The IME owns the keys while it composes (the UI doesn't send them; this is a guard).
if self.preedit.is_some() {
return vec![];
}
let Some(t) = self.current(cx) else {
self.editing = None;
return vec![];
};
let text = t.plain_text();
let len = text.len();
self.caret = self.caret.min(len);
self.anchor = self.anchor.min(len);
let (a, b) = self.sel();
let word = mods.cmd || mods.alt;
let lay = self.layout(&t);
let key = if lay.vertical {
match key {
ToolKey::Up => ToolKey::Left,
ToolKey::Down => ToolKey::Right,
ToolKey::Right => ToolKey::Up,
ToolKey::Left => ToolKey::Down,
other => other,
}
} else {
key
};
let vertical = matches!(key, ToolKey::Up | ToolKey::Down);
if !vertical {
self.goal_x = None;
}
match key {
ToolKey::Escape => self.finish(cx),
ToolKey::Enter => self.replace(cx, "\n", None),
ToolKey::Tab => self.replace(cx, "\t", None),
ToolKey::Backspace | ToolKey::Delete if a != b => self.replace(cx, "", None),
ToolKey::Backspace if self.caret > 0 => {
self.anchor = if word { edit::prev_word(&text, self.caret) } else { edit::prev_char(&text, self.caret) };
self.replace(cx, "", None)
}
ToolKey::Delete if self.caret < len => {
self.anchor = if word { edit::next_word(&text, self.caret) } else { edit::next_char(&text, self.caret) };
self.replace(cx, "", None)
}
ToolKey::Left => {
let to = if a != b && !mods.shift {
a
} else if word {
edit::prev_word(&text, self.caret)
} else {
edit::prev_char(&text, self.caret)
};
self.move_to(to, mods.shift)
}
ToolKey::Right => {
let to = if a != b && !mods.shift {
b
} else if word {
edit::next_word(&text, self.caret)
} else {
edit::next_char(&text, self.caret)
};
self.move_to(to, mods.shift)
}
ToolKey::Up | ToolKey::Down => {
let d = if key == ToolKey::Up { -1 } else { 1 };
let from = if a != b && !mods.shift { if d < 0 { a } else { b } } else { self.caret };
let x = self.goal_x.unwrap_or_else(|| lay.logical_point(vectorcraft_text::caret_position(&lay, from).0).x);
let to = if mods.cmd {
// Cmd+Up/Down: paragraph start / end.
let p = edit::paragraph_at(&text, from);
if d < 0 {
if from == p.start { edit::paragraph_at(&text, p.start.saturating_sub(1)).start } else { p.start }
} else if from == p.end {
edit::paragraph_at(&text, (p.end + 1).min(len)).end
} else {
p.end
}
} else {
vectorcraft_text::caret_vertical(&lay, from, d, x)
};
let out = self.move_to(to, mods.shift);
self.goal_x = Some(x);
out
}
ToolKey::Home => {
let to = if mods.cmd { 0 } else { vectorcraft_text::line_home(&lay, self.caret) };
self.move_to(to, mods.shift)
}
ToolKey::End => {
let to = if mods.cmd { len } else { vectorcraft_text::line_end_of(&lay, self.caret) };
self.move_to(to, mods.shift)
}
_ => vec![],
}
}
fn ime_preedit(&mut self, cx: &ToolContext, text: &str, active_chars: Option<Range<usize>>) -> Vec<Action> {
if self.editing.is_none() {
return vec![];
}
// A clear with nothing marked (some integrations send one when the IME is enabled) must
// not delete the selection.
if text.is_empty() && self.preedit.is_none() {
return vec![];
}
let text: String = text.chars().filter(|c| !c.is_control()).collect();
// The new marked text replaces the previous one, or the selection when it starts.
if let Some(p) = self.preedit.take() {
self.anchor = p.range.start;
self.caret = p.range.end;
}
self.clicks = (None, 0);
let mut out = self.replace(cx, &text, None);
if text.is_empty() {
// Cancelled before anything else was typed: no empty undo step.
if self.typing.as_ref().is_some_and(|ty| ty.cur == ty.base) {
self.typing = None;
out.push(Action::Cancel);
}
return out;
}
let end = self.caret;
let start = end.saturating_sub(text.len());
self.preedit = Some(Preedit { range: start..end, active: active_chars.and_then(|r| char_range_to_bytes(&text, r)) });
out
}
fn composing(&self) -> bool {
self.preedit.is_some()
}
fn ime_caret(&self, cx: &ToolContext) -> Option<(Point, Point)> {
let t = self.current(cx)?;
let lay = self.layout(&t);
let len = t.plain_text().len();
// The candidate window follows the clause being converted, else the caret.
let at = match &self.preedit {
Some(p) => p.range.start + p.active.as_ref().map_or(0, |r| r.start),
None => self.caret.min(self.anchor),
};
let (a, b) = vectorcraft_text::caret_position(&lay, at.min(len));
Some((t.xf * a, t.xf * b))
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.finish(cx)
}
/// Editing ends when a command takes the text out of the selection (Deselect, another object
/// picked in the Layers panel) or removes it.
fn after_command(&mut self, cx: &ToolContext) -> Vec<Action> {
let Some(id) = self.editing else { return vec![] };
let mut cur = Some(id).filter(|i| cx.doc.node(*i).is_some());
while let Some(c) = cur {
if cx.selection.contains(c) {
return vec![];
}
cur = cx.doc.parent_of(c);
}
self.finish(cx)
}
/// After `text.create` / `text.createInPath` the engine selects the new object; edit it.
fn notify(&mut self, cx: &ToolContext, what: &str) {
if what == "text.editNew"
&& let Some(id) = cx.selection.objects.first().copied()
{
self.start_editing(id, 0);
self.fresh = Self::text(cx, id).filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id);
}
}
/// `{editing, start, end, caret, anchor, typing, composing}` — the Character panel styles
/// `start..end`.
fn options(&self) -> Value {
match self.editing {
Some(id) => {
// Offsets may exceed the text after Select All; callers clamp to the text length.
let (a, b) = self.sel();
json!({"editing": id.0, "start": a, "end": b, "caret": self.caret, "anchor": self.anchor, "typing": self.typing.is_some(), "composing": self.preedit.is_some()})
}
None => json!({"editing": null}),
}
}
/// `select {start, end}` / `selectAll` / `copy` (with `runs` of the selection) /
/// `commitTyping` (the caller commits the engine interaction).
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"commitTyping" | "endTyping" => {
self.typing = None;
self.preedit = None;
}
"selectAll" if self.editing.is_some() => {
self.anchor = 0;
self.caret = usize::MAX / 4;
}
"select" if self.editing.is_some() => {
let g = |k: &str| v.get(k).and_then(Value::as_u64).map(|x| x as usize);
if let (Some(a), Some(b)) = (g("start"), g("end")) {
self.anchor = a;
self.caret = b;
}
}
"copy" => {
if let Ok(runs) = serde_json::from_value::<Vec<TextRun>>(v.clone()) {
self.clipboard = runs;
}
}
"stopEditing" => {
self.typing = None;
self.preedit = None;
self.editing = None;
}
_ => {}
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = vec![];
if let (Some(s), Some(d)) = (self.press, self.drag) {
o.push(Overlay::Marquee(Rect::from_points(s, d)));
}
// Overflow markers on selected text.
for id in &cx.selection.objects {
if Some(*id) == self.editing {
continue;
}
if let Some(t) = Self::text(cx, *id) {
let lay = self.layout(t);
Self::overflow_marker(t, &lay, cx, &mut o);
}
}
let Some(t) = self.current(cx) else { return o };
let lay = self.layout(&t);
let len = t.plain_text().len();
// Frame / baseline path of the edited object.
let frame: Option<BezPath> = match &t.kind {
TextKind::Area { frame } => Some(frame.to_bezpath()),
TextKind::OnPath { path, .. } => Some(path.to_bezpath()),
TextKind::Point => None,
};
if let Some(mut f) = frame {
f.apply_affine(t.xf);
o.push(Overlay::Path { path: f, color: [79, 128, 255], width: 1.0, dashed: false });
}
let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len));
if let Some(pe) = &self.preedit {
// Marked text: a thin underline, a thick one under the clause being converted.
let under = |o: &mut Vec<Overlay>, r: Range<usize>, width: f32| {
for q in vectorcraft_text::selection_quads(&lay, r.start.min(len), r.end.min(len)) {
let (l, rr) = (t.xf * q[3], t.xf * q[2]);
o.push(Overlay::Path { path: vectorcraft_geom::Line::new(l, rr).to_path(0.1), color: [0, 0, 0], width, dashed: false });
}
};
under(&mut o, pe.range.clone(), 1.0);
match &pe.active {
Some(r) if !r.is_empty() => under(&mut o, pe.range.start + r.start..pe.range.start + r.end, 2.5),
_ => {
let at = pe.range.start + pe.active.as_ref().map_or(pe.range.len(), |r| r.start);
let (p, q) = vectorcraft_text::caret_position(&lay, at.min(len));
o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false });
}
}
} else if a == b {
let (p, q) = vectorcraft_text::caret_position(&lay, self.caret.min(len));
o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false });
} else {
for q in vectorcraft_text::selection_quads(&lay, a, b) {
o.push(Overlay::Highlight { quad: q.map(|p| t.xf * p), color: [60, 120, 255, 90] });
}
}
Self::overflow_marker(&t, &lay, cx, &mut o);
o
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
if self.mode != Mode::Type && Self::path_at(cx, p, self.mode == Mode::Area).is_some() {
return Cursor::Crosshair;
}
Cursor::Text
}
}
#[cfg(test)]
#[path = "text_tests.rs"]
mod tests;