refactor(tools): move text.rs items to text/{plain,ports,tool}.rs

This commit is contained in:
aiongg
2026-10-26 21:45:33 +00:00
parent 1b9881ce1e
commit 714aecc7b4
4 changed files with 1067 additions and 1046 deletions
+11 -1046
View File
File diff suppressed because it is too large Load Diff
+235
View File
@@ -0,0 +1,235 @@
use super::*;
/// 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.
/// The `type.step` an Alt+arrow asks for, by one step: ←/→ kerning at a `caret`, else tracking;
/// ↑/↓ leading (down opens it up); Shift+↑/↓ baseline shift.
pub fn step_key(key: ToolKey, shift: bool, caret: bool) -> Option<(&'static str, f64)> {
let sign = |up: bool| if up { 1.0 } else { -1.0 };
Some(match (key, shift) {
(ToolKey::Left | ToolKey::Right, false) => (if caret { "kerning" } else { "tracking" }, sign(key == ToolKey::Right)),
(ToolKey::Up | ToolKey::Down, false) => ("leading", sign(key == ToolKey::Down)),
(ToolKey::Up | ToolKey::Down, true) => ("baselineShift", sign(key == ToolKey::Up)),
_ => return None,
})
}
pub(super) 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 (at most `max_prefix`) and suffix of `a` and `b` (on char
/// boundaries, not overlapping).
pub(super) fn common_affixes(a: &str, b: &str, max_prefix: usize) -> (usize, usize) {
let mut p = 0;
for ((i, x), y) in a.char_indices().zip(b.chars()) {
if x != y || i + x.len_utf8() > max_prefix {
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)
}
/// Byte length of the borrowed plain-text segments.
pub(super) fn plain_len(segments: &[&str]) -> usize {
segments.iter().map(|s| s.len()).sum()
}
/// Is `byte` a UTF-8 boundary in the concatenation of `segments`? Run edges are boundaries.
pub(super) fn plain_is_boundary(segments: &[&str], byte: usize) -> bool {
let mut at = 0usize;
for segment in segments {
if byte == at {
return true;
}
if byte < at + segment.len() {
return segment.is_char_boundary(byte - at);
}
at += segment.len();
}
byte == at
}
/// Append the bytes `start..end` of the concatenation of `segments` to `out`.
fn copy_plain_range(segments: &[&str], start: usize, end: usize, out: &mut String) {
let mut at = 0usize;
for segment in segments {
let segment_end = at + segment.len();
let from = start.max(at);
let to = end.min(segment_end);
if from < to {
let (a, b) = (from - at, to - at);
if segment.is_char_boundary(a) && segment.is_char_boundary(b) {
out.push_str(&segment[a..b]);
}
}
if segment_end >= end {
break;
}
at = segment_end;
}
}
/// Move `i` back to a character boundary in the concatenation of `segments` (deleting fewer
/// bytes rather than splitting UTF-8).
pub(super) fn plain_floor_boundary(segments: &[&str], mut i: usize) -> usize {
i = i.min(plain_len(segments));
while i > 0 && !plain_is_boundary(segments, i) {
i -= 1;
}
i
}
/// Move `i` forward to a character boundary in the concatenation of `segments`.
pub(super) fn plain_ceil_boundary(segments: &[&str], mut i: usize) -> usize {
let len = plain_len(segments);
i = i.min(len);
while i < len && !plain_is_boundary(segments, i) {
i += 1;
}
i
}
/// The byte offset `n` characters before `at` (a boundary) in `segments`, stopping at the start of
/// the text. Walks the runs, so a large IME deletion count never copies the whole text.
pub(super) fn plain_chars_before(segments: &[&str], at: usize, n: usize) -> usize {
if n == 0 {
return at;
}
let mut remaining = n;
let mut end = plain_len(segments);
for segment in segments.iter().rev() {
let start = end - segment.len();
let local_end = at.min(end).saturating_sub(start);
if local_end > 0 && segment.is_char_boundary(local_end) {
let before = &segment[..local_end];
let count = before.chars().count();
if count >= remaining {
return start + before.char_indices().rev().nth(remaining.saturating_sub(1)).map_or(0, |(i, _)| i);
}
remaining -= count;
}
end = start;
}
0
}
/// The byte offset `n` characters after `at` (a boundary) in `segments`, stopping at the end of
/// the text.
pub(super) fn plain_chars_after(segments: &[&str], at: usize, n: usize) -> usize {
if n == 0 {
return at;
}
let len = plain_len(segments);
let mut remaining = n;
let mut acc = 0usize;
for segment in segments {
let end = acc + segment.len();
let local_start = at.saturating_sub(acc);
if end > at && segment.is_char_boundary(local_start) {
let after = &segment[local_start..];
let count = after.chars().count();
if count >= remaining {
return acc + local_start + after.char_indices().nth(remaining).map_or(after.len(), |(i, _)| i);
}
remaining -= count;
}
acc = end;
}
len
}
/// The logical IME surrounding window: the pre-edit is removed and the caret/anchor offsets
/// are remapped around it. `preedit` has already been validated against `segments`.
pub(super) fn ime_surrounding_window(
segments: &[&str],
preedit: Option<Range<usize>>,
len: usize,
caret: usize,
anchor: usize,
max_bytes: usize,
) -> ImeSurrounding {
let removed = preedit.as_ref().map_or(0, |r| r.end - r.start);
let logical_len = len - removed;
let to_logical = |byte: usize| -> usize {
match &preedit {
Some(r) if byte > r.start && byte < r.end => r.start,
Some(r) if byte >= r.end => byte - removed,
_ => byte,
}
};
let cursor = to_logical(caret);
let anchor = to_logical(anchor);
let selection_start = cursor.min(anchor);
let selection_end = cursor.max(anchor);
let selection_len = selection_end - selection_start;
if selection_len > max_bytes {
return ImeSurrounding::Unavailable("the selection is wider than the IME surrounding-text limit");
}
let budget = max_bytes - selection_len;
let before_available = selection_start;
let after_available = logical_len - selection_end;
let mut before = (budget / 2).min(before_available);
let mut after = (budget - before).min(after_available);
before = (budget - after).min(before_available);
after = (budget - before).min(after_available);
let mut window_start = selection_start - before;
let mut window_end = selection_end + after;
let logical_boundary = |byte: usize| -> bool {
match &preedit {
Some(r) if byte == r.start => true,
Some(r) if byte < r.start => plain_is_boundary(segments, byte),
Some(_) => plain_is_boundary(segments, byte + removed),
None => plain_is_boundary(segments, byte),
}
};
while window_start < selection_start && !logical_boundary(window_start) {
window_start += 1;
}
while selection_end < window_end && !logical_boundary(window_end) {
window_end -= 1;
}
let to_original = |byte: usize| -> usize {
match &preedit {
Some(r) if byte > r.start => byte + removed,
_ => byte,
}
};
let original_start = to_original(window_start);
let original_end = to_original(window_end);
let mut text = String::with_capacity(window_end - window_start);
match &preedit {
Some(r) => {
if window_start < r.start {
let first_end = original_end.min(r.start);
if original_start < first_end {
copy_plain_range(segments, original_start, first_end, &mut text);
}
}
if r.start < window_end {
let second_start = r.end.max(original_start);
if second_start < original_end {
copy_plain_range(segments, second_start, original_end, &mut text);
}
}
}
None => {
if original_start < original_end {
copy_plain_range(segments, original_start, original_end, &mut text);
}
}
}
debug_assert_eq!(text.len(), window_end - window_start, "removing the pre-edit must preserve the logical window length");
ImeSurrounding::Available { text, cursor: cursor - window_start, anchor: anchor - window_start }
}
+189
View File
@@ -0,0 +1,189 @@
use super::*;
/// How far (screen px) the ports of area type sit from the frame's corners, along its sides: the
/// out port above the bottom-right corner on the right side, the in port below the top-left corner
/// on the left side, clear of the bounding box's corner handles, so both can be grabbed.
pub const PORT_RISE_PX: f64 = 12.0;
/// Half a port's side (screen px).
const PORT_HALF_PX: f64 = 4.5;
/// The red of the out port of text that doesn't fit.
const OVERFLOW_RED: [u8; 3] = [230, 30, 30];
/// One of the two ports of area type or type on a path: its story flows in through the in port
/// and on through the out port.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Port {
In,
Out,
}
/// What a port shows: empty when the frame is not threaded on that side (and, for the out port,
/// all its text is seen), an arrow when it is threaded to another frame there, a red "+" in the out
/// port when its text doesn't fit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PortState {
Empty,
Threaded,
Overflow,
}
/// Centre of `port` of `t`, `px` document units to the screen pixel: area type's on its frame's
/// sides by [`PORT_RISE_PX`] from the corners; type on a path's along its path, as far past its end
/// bracket (out) or before its start bracket (in), clear of the brackets, which drag.
pub fn port_centre(t: &TextObject, port: Port, px: f64) -> Option<Point> {
let rise = PORT_RISE_PX * px;
match &t.kind {
TextKind::Area { frame } => {
let b = frame.bounds()?;
// The corner the port sits by, and the far end of the side it sits on.
let (corner, far) = match port {
Port::Out => (Point::new(b.x1, b.y1), Point::new(b.x1, b.y0)),
Port::In => (Point::new(b.x0, b.y0), Point::new(b.x0, b.y1)),
};
let (corner, side) = (t.xf * corner, t.xf * far - t.xf * corner);
let len = side.hypot();
Some(if len > 1e-9 && len.is_finite() { corner + side * (rise.min(len / 2.0) / len) } else { corner })
}
TextKind::OnPath { path, .. } => {
let arc = vectorcraft_geom::ArcPath::new(path);
let (start, end) = t.kind.path_span()?;
if arc.is_empty() {
return None;
}
let (at, away) = match port {
Port::Out => (end, rise),
Port::In => (start, -rise),
};
let (p, dir) = arc.at(at * arc.len());
let (at, ahead) = (t.xf * p, t.xf * (p + dir) - t.xf * p);
let len = ahead.hypot();
Some(if len > 1e-9 && len.is_finite() { at + ahead * (away / len) } else { at })
}
TextKind::Point => None,
}
}
/// What the in and out ports of frame `id` of `d` show, its text `overflow`ing or not: an arrow
/// where a frame of its thread comes before (in) or after (out) it, else a red "+" in the out port
/// of text that doesn't fit, else nothing.
pub fn port_states(d: &vectorcraft_doc::Document, id: NodeId, overflow: bool) -> (PortState, PortState) {
let thread = d.text_threads.iter().find(|t| t.contains(&id));
let at = thread.and_then(|t| t.iter().position(|x| *x == id));
let input = if at.is_some_and(|i| i > 0) { PortState::Threaded } else { PortState::Empty };
let output = match (thread, at) {
(Some(t), Some(i)) if i + 1 < t.len() => PortState::Threaded,
_ if overflow => PortState::Overflow,
_ => PortState::Empty,
};
(input, output)
}
/// A port at `c` showing `state`, `px` document units to the screen pixel: a white box outlined in
/// `color` (the frame's layer colour; red for text that doesn't fit), with a red "+" or an arrow
/// pointing the way the story flows.
pub fn push_port(o: &mut Vec<Overlay>, c: Point, state: PortState, color: [u8; 3], px: f64) {
let h = PORT_HALF_PX * px;
let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h);
let color = if state == PortState::Overflow { OVERFLOW_RED } else { color };
let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)];
o.push(Overlay::Highlight { quad: corners, color: [255, 255, 255, 255] });
o.push(Overlay::Path { path: r.to_path(0.1), color, width: 1.0, dashed: false });
let k = 2.5 * px;
let line = |a: Point, b: Point| Overlay::Line { a, b, color, dashed: false };
match state {
PortState::Empty => {}
PortState::Overflow => {
o.push(line(Point::new(c.x - k, c.y), Point::new(c.x + k, c.y)));
o.push(line(Point::new(c.x, c.y - k), Point::new(c.x, c.y + k)));
}
PortState::Threaded => {
let tip = Point::new(c.x + k, c.y);
o.push(line(Point::new(c.x - k, c.y), tip));
o.push(line(Point::new(c.x, c.y - k), tip));
o.push(line(Point::new(c.x, c.y + k), tip));
}
}
}
/// The in and out ports of area type or type on a path `t`, frame `id`, its text `overflow`ing or
/// not ([`port_states`]).
pub(super) fn ports(cx: &ToolContext, id: NodeId, t: &TextObject, overflow: bool, o: &mut Vec<Overlay>) {
let (input, output) = port_states(cx.doc, id, overflow);
let (color, px) = (cx.doc.layer_color(id), cx.tol(1.0));
for (port, state) in [(Port::In, input), (Port::Out, output)] {
if let Some(c) = port_centre(t, port, px) {
push_port(o, c, state, color, px);
}
}
}
/// The most selected area type and type on a path whose ports are drawn and take clicks: selecting
/// all of an opened PDF can select tens of thousands of type objects, whose ports would only bury
/// the canvas (and the GPU's buffers) in boxes.
pub const PORTS_MAX: usize = 1_000;
/// The text of `id` in `d` when it has ports: area type or type on a path a thread can hold, not in
/// perspective ([`crate::select::is_threadable`]). The one test for drawing ports, clicking them and
/// counting them toward [`PORTS_MAX`].
pub fn ported(d: &vectorcraft_doc::Document, id: NodeId) -> Option<&TextObject> {
match d.node(id).filter(|n| crate::select::is_threadable(n)).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t),
_ => None,
}
}
/// Are at most [`PORTS_MAX`] of `selected` [`ported`] (counting stops past it), so that their
/// ports show?
pub fn ports_shown(d: &vectorcraft_doc::Document, selected: &[NodeId]) -> bool {
selected.iter().filter(|id| ported(d, **id).is_some()).take(PORTS_MAX + 1).count() <= PORTS_MAX
}
/// The selected area type or type on a path with a port under `p`, and which port: a click on the
/// out port loads the Selection tool with its text to thread into another frame after it, a click
/// on the in port to thread into one before it. Every such frame has both ports, threaded,
/// overflowing or not, while their ports show ([`PORTS_MAX`]).
pub(crate) fn port_at(cx: &ToolContext, p: Point) -> Option<(NodeId, Port)> {
if !ports_shown(cx.doc, &cx.selection.objects) {
return None;
}
let h = cx.tol(PORT_HALF_PX + 1.0);
let near = |c: Point| (c.x - p.x).abs() <= h && (c.y - p.y).abs() <= h;
cx.selection.objects.iter().find_map(|id| {
let t = ported(cx.doc, *id)?;
[Port::Out, Port::In].into_iter().find(|port| port_centre(t, *port, cx.tol(1.0)).is_some_and(near)).map(|port| (*id, port))
})
}
/// Which selected text overflows, for [`thread_ports`]: each text object's layout verdict kept
/// with the object and the fonts it was made from, as overlays run every frame.
#[derive(Default)]
pub struct PortMarks {
seen: std::collections::HashMap<NodeId, ((u64, TextObject), bool)>,
}
/// The in and out ports of the selected area type and type on a path, for every tool but the Type
/// tools (they draw their own): each tells whether the frame is threaded on that side and the out
/// port whether text is hidden by the size of its frame, whatever tool is active. None when more
/// than [`PORTS_MAX`] of them are selected.
pub fn thread_ports(marks: &mut PortMarks, cx: &ToolContext) -> Vec<Overlay> {
let fonts = FontDb::global().generation();
let mut o = Vec::new();
let mut kept = std::collections::HashMap::new();
if !ports_shown(cx.doc, &cx.selection.objects) {
marks.seen = kept;
return o;
}
for id in &cx.selection.objects {
let Some(t) = ported(cx.doc, *id) else { continue };
let over = match marks.seen.remove(id) {
Some(((g, was), over)) if g == fonts && &was == t => over,
_ => vectorcraft_text::layout(FontDb::global(), t).overflow,
};
ports(cx, *id, t, over, &mut o);
kept.insert(*id, ((fonts, t.clone()), over));
}
marks.seen = kept;
o
}
+632
View File
@@ -0,0 +1,632 @@
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::*;
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<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);
// 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<Action> {
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<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();
// 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<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
}
/// 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<Action> {
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<Action> {
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<Action> {
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::<Vec<TextRun>>(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<Overlay> {
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<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] });
}
}
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 }
}
}