use super::plain::{ char_range_to_bytes, ime_surrounding_window, plain_ceil_boundary, plain_chars_after, plain_chars_before, plain_floor_boundary, plain_is_boundary, plain_len, step_key, }; use super::ports::{ported, ports, ports_shown}; use super::{ Action, BezPath, Cursor, ImeSurrounding, Mode, Mods, NodeId, Overlay, Point, PointerEvent, PointerKind, Preedit, Range, Rect, Shape, SurroundingUnit, TextKind, TextRun, Tool, ToolContext, ToolKey, TypeTool, Value, edit, json, }; 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() } /// Escape while editing is the tool's: it goes on to the Selection tool (#1686). fn claims_key(&self, _: &ToolContext, key: ToolKey) -> bool { key == ToolKey::Escape && self.editing.is_some() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { 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); // A press on other type edits it from there, so a drag selects its text at once, // as it does in the type being edited. if let Some(out) = self.edit_at(cx, ev.pos) { self.selecting = true; return out; } self.press = Some((ev.pos, self.snap.press(cx, ev.pos, self.editing.as_slice(), None))); 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(self.snap.drag(cx, ev.pos, None)); } vec![] } PointerKind::Up => { if self.selecting { self.selecting = false; return vec![]; } self.snap.clear(); let Some((start, at)) = self.press.take() else { return vec![] }; self.on_up(cx, start, at) } 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 => { // The type being edited is no target: its guides would only point at itself. self.snap.hover(cx, ev.pos, self.editing.as_slice(), None); vec![] } } } fn text_input(&mut self, cx: &ToolContext, s: &str) -> Vec { if self.editing.is_none() { return vec![]; } // An IME commit replaces its marked text (an empty commit just removes it). let committing = self.preedit.is_some(); // A space typed on its own at a caret may start a list. let list_space = !committing && s == " " && self.caret == self.anchor; 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; } let Some(s) = self.input_text(cx, s) else { return vec![] }; 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)); } let mut out = self.replace(cx, &s, None); if list_space { out.extend(self.start_typed_list(cx)); } out } /// Would [`Tool::text_input`] change `s` before inserting it? Control characters are dropped, /// `\r` becomes `\n`, Typographer's Quotes rewrites a straight quote, and copied text keeps /// its run styles. An IME commit the application rewrites is an application edit, not an /// accepted IME insertion (its receipt must stay `Other`). fn text_input_is_verbatim(&self, cx: &ToolContext, s: &str) -> bool { if self.editing.is_none() { return true; } if s.is_empty() { return true; } // A straight quote's direction depends on the character before the insertion. During a // commit this check still sees the pre-edit at the caret, while `text_input` replaces it // from its start; with a custom pair whose opening quote is straight, that can look // verbatim here and then be rewritten. Never claim verbatim for one. if cx.doc.setup.typographers_quotes && matches!(s, "\"" | "'") { return false; } let Some(normalized) = self.input_text(cx, s) else { return false }; if normalized != s { return false; } let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); !(self.preedit.is_none() && normalized.len() > 1 && normalized == clip && !cx.paste_plain_text) } /// Whether the typing session still describes the edited document; a session ended by an outside /// command reports false until a replacement commit starts a new one. fn ime_session_matches_document(&self, cx: &ToolContext) -> bool { match (self.editing, &self.typing) { (Some(id), Some(ty)) => Self::text(cx, id).is_none_or(|t| ty.cur == t.runs), // An outside command settled the session: the document may have changed under it, and // the commit that starts a new session cannot claim the IME made that change. (Some(_), None) => !self.typing_ended, _ => true, } } /// Handle an editing key: ignore keys while an IME composes, remap arrows in vertical text, step /// type attributes with Alt, and otherwise edit, navigate or end the session. fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { // 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(); // Alt+arrows step the type by the Preferences › Type increments (Cmd/Ctrl too: five steps). if mods.alt && let Some(id) = self.editing && let Some((attribute, by)) = step_key(key, mods.shift, a == b) { let mut out = self.commit(); let by = if mods.cmd { by * 5.0 } else { by }; out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by}))); return out; } 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 { // Escape ends editing and goes back to the tool used before double-clicking into type // (or to the Selection tool by default), the type selected: the keyboard is free for // tool shortcuts again (#1686). ToolKey::Escape => { let target = self.prev_tool.take().unwrap_or_else(|| "selection".into()); let mut out = self.finish(cx); out.push(Action::SwitchTool(target)); out } 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) } // Backspace takes back one character (a mistyped Thai tone mark, say); Delete, the // Left and Right arrows a whole cluster. ToolKey::Delete if self.caret < len => { self.anchor = if word { edit::next_word(&text, self.caret) } else { edit::next_cluster(&text, self.caret) }; self.replace(cx, "", None) } ToolKey::Left => { let to = if a != b && !mods.shift { if lay.glyphs.iter().any(|g| g.rtl) && vectorcraft_text::caret_position(&lay, a).0.x > vectorcraft_text::caret_position(&lay, b).0.x { b } else { a } } else if word { edit::prev_word(&text, self.caret) } else { if lay.glyphs.iter().any(|g| g.rtl) { vectorcraft_text::caret_horizontal_cluster(&lay, &text, self.caret, false) } else { edit::prev_cluster(&text, self.caret) } }; self.move_to(to, mods.shift) } ToolKey::Right => { let to = if a != b && !mods.shift { if lay.glyphs.iter().any(|g| g.rtl) && vectorcraft_text::caret_position(&lay, a).0.x > vectorcraft_text::caret_position(&lay, b).0.x { a } else { b } } else if word { edit::next_word(&text, self.caret) } else { if lay.glyphs.iter().any(|g| g.rtl) { vectorcraft_text::caret_horizontal_cluster(&lay, &text, self.caret, true) } else { edit::next_cluster(&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>) -> Vec { 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 } /// Delete the requested bytes or characters before and after the selection or marked text, /// preserving the selection direction and composition and refusing a stale session; byte counts /// round inward to UTF-8 boundaries. fn ime_delete_surrounding(&mut self, cx: &ToolContext, before: usize, after: usize, unit: SurroundingUnit) -> Vec { let Some(id) = self.editing else { return vec![] }; // The document changed under the session (undo, a panel edit): the ranges below would index // text that is no longer there. The commit that follows resets the session; do not delete // from the wrong text in the meantime. if let Some(t) = Self::text(cx, id) && self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs) { return vec![]; } // Walk the runs in place: joining the whole edited text here would copy a large document // for every IME deletion event, though only the characters next to the selection matter. let Some(segments) = self.plain_segments(cx) else { return vec![] }; let len = plain_len(&segments); // The text the IME's following commit replaces (the selection, or the marked text while // composing). The surrounding deletion never removes it: it deletes only the bytes/characters // before and after it, preserving the selection direction and the composition. let (raw_start, raw_end) = match &self.preedit { Some(p) => (p.range.start.min(len), p.range.end.min(len)), None => (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len)), }; let (before_start, start, end, after_end) = match unit { SurroundingUnit::Bytes => { // Inward rounding: a count ending inside a scalar leaves that scalar whole. The // preceding side moves its start forward (ceil), the following side moves its end // backward (floor). Do not reverse these: that would delete part of a character. let start = plain_floor_boundary(&segments, raw_start); let end = plain_ceil_boundary(&segments, raw_end); let before_start = plain_ceil_boundary(&segments, start.saturating_sub(before)); let after_end = plain_floor_boundary(&segments, end.saturating_add(after)); (before_start, start, end, after_end) } SurroundingUnit::Chars => { let start = plain_floor_boundary(&segments, raw_start); let end = plain_ceil_boundary(&segments, raw_end); let before_start = plain_chars_before(&segments, start, before); let after_end = plain_chars_after(&segments, end, after); (before_start, start, end, after_end) } }; let mut ranges = Vec::with_capacity(2); if before_start < start { ranges.push((before_start, start)); } if end < after_end { ranges.push((end, after_end)); } if ranges.is_empty() { return vec![]; } self.delete_ranges(cx, &ranges) } /// Return the active Type tool's stable IME owner (document uid, target id, replacement epoch), /// or `None` when nothing is being edited. fn ime_target(&self, cx: &ToolContext) -> Option<(u64, u64, u64)> { let id = self.editing?; Self::text(cx, id)?; Some((cx.revision.0, id.0, self.edit_session)) } /// Return a bounded logical text window around the caret, anchor and optional pre-edit; a stale /// typing session publishes the document's current text, and unusable or non-boundary offsets /// return `Unavailable` rather than stale data. fn ime_surrounding(&self, cx: &ToolContext, max_bytes: usize) -> ImeSurrounding { let Some(id) = self.editing else { return ImeSurrounding::Unavailable("no text object is being edited"); }; let Some(current) = Self::text(cx, id) else { return ImeSurrounding::Unavailable("the edited text object is gone"); }; // The document changed under the typing session (undo, a panel or control edit): the // session's runs, marked-text range and caret offsets no longer describe the document. // Publish the document's own text (never the stale session's, which the IME cannot see); // the marked text has no range in it, so it is excluded from the logical view. The // commit-time check (`ime_session_matches_document`) reports the reset as application // origin instead of letting a receipt describe the wrong text. let stale = self.typing.as_ref().is_some_and(|ty| ty.cur != current.runs); if self.preedit.is_some() && self.typing.is_none() { return ImeSurrounding::Unavailable("the IME pre-edit has no typing session"); } let Some(segments) = self.plain_segments(cx) else { return ImeSurrounding::Unavailable("the edited text object is gone"); }; let len = plain_len(&segments); let caret = self.caret.min(len); let anchor = self.anchor.min(len); if !plain_is_boundary(&segments, caret) || !plain_is_boundary(&segments, anchor) { return ImeSurrounding::Unavailable("the caret or selection anchor is not a UTF-8 boundary"); } let preedit = match self.preedit.as_ref().filter(|_| !stale) { Some(p) => { let start = p.range.start.min(len); let end = p.range.end.min(len); if start > end || !plain_is_boundary(&segments, start) || !plain_is_boundary(&segments, end) { return ImeSurrounding::Unavailable("the IME pre-edit is not on UTF-8 boundaries"); } Some(start..end) } None => None, }; ime_surrounding_window(&segments, preedit, len, caret, anchor, max_bytes) } /// Whether an IME pre-edit is currently shown for this Type-tool session. 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 { self.prev_tool = None; 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. After an Undo that takes back a list typed /// characters started, the caret goes on after the characters, and after its Redo the caret /// goes before the paragraph's text. fn after_command(&mut self, cx: &ToolContext) -> Vec { self.after_typed_list(cx); 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); let t = Self::text(cx, id); self.fresh = t.filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id); // Placeholder text comes selected: typing replaces it. self.caret = t.map_or(0, |t| edit::runs_len(&t.runs)); } } /// `{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(), "prevTool": self.prev_tool, }) } None => json!({"editing": null, "prevTool": self.prev_tool}), } } /// `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 { // Transient selection transferred between Type and Eyedropper; never a saved setting. "textSelection" => { if let (Some(id), Some(a), Some(b)) = ( v.get("editing").and_then(Value::as_u64), v.get("anchor").or_else(|| v.get("start")).and_then(Value::as_u64), v.get("caret").or_else(|| v.get("end")).and_then(Value::as_u64), ) && let (Ok(a), Ok(b)) = (usize::try_from(a), usize::try_from(b)) { self.start_editing(NodeId(id), b); self.anchor = a; } } "prevTool" | "previousTool" => { self.prev_tool = v.as_str().map(String::from); } "commitTyping" | "endTyping" => { self.typing = None; self.preedit = None; self.typing_ended = true; } "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::>(v.clone()) { self.clipboard = runs; } } "stopEditing" => { self.typing = None; self.preedit = None; self.editing = None; self.prev_tool = None; } _ => {} } } fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = self.snap.guides().to_vec(); if let (Some((_, s)), Some(d)) = (self.press, self.drag) { // The area being drawn (along the Constrain Angle's axes when one is set, as `on_up` // makes it) and its size. let size = match vectorcraft_geom::constrain_axes(cx.constrain_angle) { None => { let r = Rect::from_points(s, d); o.push(Overlay::Marquee(r)); r.size() } Some(axes) => { let back = axes.inverse(); let r = Rect::from_points(back * s, back * d); let mut path = r.to_path(0.1); path.apply_affine(axes); o.push(Overlay::Path { path, color: [79, 128, 255], width: 1.0, dashed: true }); r.size() } }; if cx.measurement_labels { o.push(Overlay::Measure { p: d, text: cx.size_label(size.width, size.height) }); } } // The ports of selected area type and type on a path, as many as show ([`PORTS_MAX`]), the // edited one's included. let shown = ports_shown(cx.doc, &cx.selection.objects); if shown { for id in cx.selection.objects.iter().filter(|id| Some(**id) != self.editing) { if let Some(t) = ported(cx.doc, *id) { let lay = self.layout(t); ports(cx, *id, t, lay.overflow, &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 = 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, r: Range, 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] }); } } if let Some(id) = self.editing.filter(|id| shown && ported(cx.doc, *id).is_some()) { ports(cx, id, &t, lay.overflow, &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; } if self.vertical { Cursor::TypeVertical } else { Cursor::Text } } }