From 714aecc7b413e3aa9e50a0f8e6dfa11ee8bc7fe0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:39:00 +0000 Subject: [PATCH] refactor(tools): move text.rs items to text/{plain,ports,tool}.rs --- crates/tools/src/text.rs | 1057 +------------------------------- crates/tools/src/text/plain.rs | 235 +++++++ crates/tools/src/text/ports.rs | 189 ++++++ crates/tools/src/text/tool.rs | 632 +++++++++++++++++++ 4 files changed, 1067 insertions(+), 1046 deletions(-) create mode 100644 crates/tools/src/text/plain.rs create mode 100644 crates/tools/src/text/ports.rs create mode 100644 crates/tools/src/text/tool.rs diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 12e9e80bf..af7370e96 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -33,6 +33,17 @@ use vectorcraft_text::{FontDb, TextLayout, edit}; use crate::guides::DrawSnap; use crate::{Action, Cursor, ImeSurrounding, Mods, Overlay, PointerEvent, PointerKind, SurroundingUnit, Tool, ToolContext, ToolKey}; +mod plain; +mod ports; +mod tool; + +#[cfg(test)] +use plain::char_range_to_bytes; +use plain::common_affixes; +pub use plain::step_key; +pub(crate) use ports::port_at; +pub use ports::{PORT_RISE_PX, PORTS_MAX, Port, PortMarks, PortState, port_centre, port_states, ported, ports_shown, push_port, thread_ports}; + /// Which of the Type tools this is. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum Mode { @@ -540,1051 +551,5 @@ impl TypeTool { } } -/// 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 { - 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, 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`]). -fn ports(cx: &ToolContext, id: NodeId, t: &TextObject, overflow: bool, o: &mut Vec) { - 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, -} - -/// 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 { - 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 -} - -/// 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, - }) -} - -fn char_range_to_bytes(s: &str, r: Range) -> Option> { - 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). -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. -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. -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). -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`. -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. -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. -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 -} - -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 } - } -} - -/// 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`. -fn ime_surrounding_window( - segments: &[&str], - preedit: Option>, - 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 } -} - #[cfg(test)] mod tests; diff --git a/crates/tools/src/text/plain.rs b/crates/tools/src/text/plain.rs new file mode 100644 index 000000000..1b058a4b6 --- /dev/null +++ b/crates/tools/src/text/plain.rs @@ -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) -> Option> { + 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>, + 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 } +} diff --git a/crates/tools/src/text/ports.rs b/crates/tools/src/text/ports.rs new file mode 100644 index 000000000..ace85694c --- /dev/null +++ b/crates/tools/src/text/ports.rs @@ -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 { + 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, 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) { + 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, +} + +/// 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 { + 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 +} diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs new file mode 100644 index 000000000..55f114a3d --- /dev/null +++ b/crates/tools/src/text/tool.rs @@ -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 { + 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 } + } +}