Files
vectorcraft/crates/tools/src/text.rs
T

1047 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Type tools: Type (T), Area Type and Type on a Path.
//!
//! Type: click places point type, drag draws an area-type frame, clicking into existing text
//! places the caret. Area Type / Type on a Path: click a path to turn it into a text frame or a
//! baseline (`text.createInPath`). While type is being edited, a click that lands on no type only
//! ends the editing, and the next click makes new type. Point type placed by a click and left
//! empty is discarded when editing ends (`text.discardEmpty`); frames keep their shape. Where new
//! type goes (the click, the frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too.
//!
//! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends
//! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a
//! word and triple-click a paragraph, typing replaces the selection. Edits are coalesced into one
//! undo step per typing session: the tool keeps the styled runs locally and previews a single
//! `text.editRange` (the changed span, with its styled runs) against the session snapshot.
//! Styling a selected range goes through `text.setRangeStyle` (the Character panel reads the
//! selection from [`Tool::options`]); Alt+arrows step it by the Preferences › Type increments
//! (`type.step`). New type starts with placeholder text, selected, when Fill New Type Objects With
//! Placeholder Text is on.
//!
//! IME: the marked text of a composition is part of the typing session (so it lays out in place),
//! underlined, until the IME commits it (it then goes through [`Tool::text_input`]) or clears it.
use std::ops::Range;
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_doc::hit::hit_test;
use vectorcraft_doc::{NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::{BezPath, Point, Rect, Shape};
use vectorcraft_text::{FontDb, TextLayout, edit};
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// Which of the Type tools this is.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum Mode {
#[default]
Type,
Area,
OnPath,
}
/// A coalesced typing session: the runs at its start and the runs now.
struct Typing {
base: Vec<TextRun>,
cur: Vec<TextRun>,
/// The first byte any edit of the session touched (the text before it is unchanged): the
/// previewed span starts no later, so Return at a paragraph's end splits that paragraph (its
/// style continues), not the next one.
lo: usize,
}
/// IME marked text inside the typing session, not yet committed.
#[derive(Clone, Debug, PartialEq)]
struct Preedit {
/// Byte range of the marked text in the session's runs.
range: Range<usize>,
/// The clause being converted, in bytes relative to `range.start` (empty: the IME's cursor).
active: Option<Range<usize>>,
}
/// The edited text's layout, keyed by the font database's generation and the text.
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
#[derive(Default)]
pub struct TypeTool {
vertical: bool,
mode: Mode,
/// The tool to return to on Escape (e.g. when double-clicked from a selection tool).
prev_tool: Option<String>,
/// Text object being edited.
editing: Option<NodeId>,
/// The point type a click just placed (edited now): discarded if empty when editing ends.
fresh: Option<NodeId>,
/// Caret and selection anchor (byte offsets into the plain text).
caret: usize,
anchor: usize,
/// Horizontal position kept by Up/Down (text space).
goal_x: Option<f64>,
typing: Option<Typing>,
preedit: Option<Preedit>,
/// Where the button went down, and where new type goes from there (snapped to Smart Guides).
press: Option<(Point, Point)>,
/// The other corner of an area dragged out (snapped).
drag: Option<Point>,
/// Smart Guides for where new type goes.
snap: DrawSnap,
/// The press landed in the edited text: dragging selects.
selecting: bool,
/// Click counting for double/triple click (position of the last click, count).
clicks: (Option<Point>, u8),
/// Styled copy of the last copied range (pasting the same plain text keeps its styles).
clipboard: Vec<TextRun>,
cache: Mutex<Option<CachedLayout>>,
}
impl TypeTool {
pub fn new(id: &str) -> Self {
let mode = match id {
"areaType" | "verticalAreaType" => Mode::Area,
"typeOnPath" | "verticalTypeOnPath" => Mode::OnPath,
_ => Mode::Type,
};
Self { mode, vertical: id.starts_with("vertical"), ..Default::default() }
}
fn text<'a>(cx: &'a ToolContext, id: NodeId) -> Option<&'a TextObject> {
match &cx.doc.node(id)?.kind {
NodeKind::Text(t) => Some(t),
_ => None,
}
}
/// Layout of `t`, cached by content (overlays run every frame) and by the fonts available (the
/// web build adds fonts after startup).
fn layout(&self, t: &TextObject) -> Arc<TextLayout> {
let fonts = FontDb::global().generation();
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(((g, k), l)) = c.as_ref()
&& *g == fonts
&& k == t
{
return l.clone();
}
let l = Arc::new(vectorcraft_text::layout(FontDb::global(), t));
*c = Some(((fonts, t.clone()), l.clone()));
l
}
fn plain(&self, cx: &ToolContext) -> String {
match &self.typing {
Some(ty) => ty.cur.iter().map(|r| r.text.as_str()).collect(),
None => self.editing.and_then(|id| Self::text(cx, id)).map(|t| t.plain_text()).unwrap_or_default(),
}
}
/// The edited object as it currently is (with the typing session's runs).
fn current(&self, cx: &ToolContext) -> Option<TextObject> {
let mut t = Self::text(cx, self.editing?)?.clone();
if let Some(ty) = &self.typing {
t.runs = ty.cur.clone();
}
Some(t)
}
fn sel(&self) -> (usize, usize) {
(self.caret.min(self.anchor), self.caret.max(self.anchor))
}
/// End the typing session (one undo step). Marked text left by an IME stays as typed (as
/// clicking away from a composition does in macOS text views); the UI interrupts the IME.
fn commit(&mut self) -> Vec<Action> {
self.preedit = None;
if self.typing.take().is_some() { vec![Action::Commit] } else { vec![] }
}
/// Stop editing. Point type a click placed that is still empty is discarded, with the steps
/// since its click (`text.discardEmpty`).
fn finish(&mut self, cx: &ToolContext) -> Vec<Action> {
let mut out = self.commit();
if let Some(id) = self.fresh.take()
&& self.editing == Some(id)
&& Self::text(cx, id).is_some_and(|t| matches!(t.kind, TextKind::Point) && edit::runs_len(&t.runs) == 0)
{
out.push(Action::Exec("text.discardEmpty".into(), json!({"id": id.0})));
}
self.editing = None;
self.selecting = false;
self.goal_x = None;
out
}
/// Replace the selection with `insert` (styled `runs` when given) inside the typing session.
fn replace(&mut self, cx: &ToolContext, insert: &str, styled: Option<Vec<TextRun>>) -> Vec<Action> {
let Some(id) = self.editing else { return vec![] };
let Some(t) = Self::text(cx, id) else {
self.editing = None;
return vec![];
};
let mut out = vec![];
// The document changed under us (undo, a panel edit): start a new session.
if self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs) {
out.push(Action::Commit);
self.typing = None;
}
if self.typing.is_none() {
out.push(Action::Begin("Typing".into()));
}
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX });
let len = edit::runs_len(&ty.cur);
let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len));
ty.lo = ty.lo.min(a);
let caret = match &styled {
Some(r) => edit::replace_range_styled(&mut ty.cur, a, b, r),
None => edit::replace_range(&mut ty.cur, a, b, insert),
};
self.caret = caret;
self.anchor = caret;
self.goal_x = None;
// One preview for the whole session: the changed span of base → cur, with its runs.
let base: String = ty.base.iter().map(|r| r.text.as_str()).collect();
let cur: String = ty.cur.iter().map(|r| r.text.as_str()).collect();
let (p, s) = common_affixes(&base, &cur, ty.lo);
let runs = edit::slice_runs(&ty.cur, p, cur.len() - s);
let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) };
out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs})));
out
}
/// Move the caret (Shift keeps the anchor). Moving ends the typing session.
fn move_to(&mut self, byte: usize, extend: bool) -> Vec<Action> {
let out = self.commit();
self.caret = byte;
if !extend {
self.anchor = byte;
}
out
}
fn start_editing(&mut self, id: NodeId, caret: usize) {
self.editing = Some(id);
self.fresh = None;
self.caret = caret;
self.anchor = caret;
self.goal_x = None;
self.typing = None;
self.preedit = None;
}
/// Byte under `p` in the edited text if `p` is inside it (frame, path band or layout bounds).
fn hit_edited(&self, cx: &ToolContext, p: Point) -> Option<usize> {
let t = self.current(cx)?;
let lay = self.layout(&t);
let local = t.xf.inverse() * p;
let tol = cx.tol(4.0);
let inside = match &t.kind {
TextKind::Area { frame } => frame.bounds().is_some_and(|b| b.inflate(tol, tol).contains(local)),
_ => lay.bounds.inflate(tol, tol).contains(local),
};
inside.then(|| vectorcraft_text::hit_byte(&lay, local))
}
fn select_word_or_para(&mut self, cx: &ToolContext, para: bool) {
let s = self.plain(cx);
let r = if para { edit::paragraph_at(&s, self.caret) } else { edit::word_at(&s, self.caret) };
self.anchor = r.start;
self.caret = r.end;
}
/// Path under `p` for the Area Type / Type on a Path tools.
fn path_at(cx: &ToolContext, p: Point, closed: bool) -> Option<NodeId> {
let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { tol: cx.tol(4.0), outline: true, ..Default::default() })?;
match &cx.doc.node(h.leaf)?.kind {
NodeKind::Path { path, .. } if !closed || path.is_closed() => Some(h.leaf),
_ => None,
}
}
/// Start editing the type under `p` with the caret there → the actions (ending the previous
/// edit, selecting the type); None when no type is under `p`. A click among the characters
/// edits them, whatever Type Object Selection by Path Only says.
fn edit_at(&mut self, cx: &ToolContext, p: Point) -> Option<Vec<Action>> {
let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { type_path_only: false, ..cx.hit_options() })?;
let Some(NodeKind::Text(t)) = cx.doc.node(h.leaf).map(|n| &n.kind) else { return None };
let lay = self.layout(t);
let byte = vectorcraft_text::hit_byte(&lay, t.xf.inverse() * p);
let mut out = self.finish(cx);
self.start_editing(h.leaf, byte);
self.clicks = (Some(p), 1);
out.push(Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]})));
Some(out)
}
/// The button released after a press at `start` (new type goes at `at`). While type is being
/// edited, a click that lands on no type only ends the editing; the next click makes new type
/// (#2871). A drag that draws an area-type frame makes it either way.
fn on_up(&mut self, cx: &ToolContext, start: Point, at: Point) -> Vec<Action> {
// Click into existing text: place the caret.
if let Some(out) = self.edit_at(cx, start) {
return out;
}
let was_editing = self.editing.is_some_and(|id| Self::text(cx, id).is_some());
let mut out = self.finish(cx);
let drag = self.drag.take();
// New type is set along General › Constrain Angle's axes (none at 0° or a whole turn): an
// area's box measured along them, turned about its top-left corner, point type about its
// first baseline's start.
let axes = vectorcraft_geom::constrain_axes(cx.constrain_angle);
let back = axes.map(|a| a.inverse());
let area = drag
.map(|d| match back {
Some(b) => Rect::from_points(b * at, b * d),
None => Rect::from_points(at, d),
})
.filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
if was_editing && area.is_none() {
return out;
}
// Area Type / Type on a Path: click a path.
if self.mode != Mode::Type && drag.is_none() {
let on_path = self.mode == Mode::OnPath;
if let Some(pid) = Self::path_at(cx, start, !on_path) {
let mode = if on_path { "onPath" } else { "area" };
out.push(Action::Exec(
"text.createInPath".into(),
json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical, "placeholder": cx.placeholder_text}),
));
out.push(Action::Notify("text.editNew".into()));
return out;
}
}
let mut params = match area {
Some(r) => {
let o = axes.map_or(r.origin(), |a| a * r.origin());
json!({"x": o.x, "y": o.y, "text": "", "area": {"width": r.width(), "height": r.height()}})
}
None => json!({"x": at.x, "y": at.y, "text": ""}),
};
if let Some(rotation) = vectorcraft_geom::constrain_rotation(cx.constrain_angle) {
params["rotation"] = json!(rotation);
}
params["vertical"] = json!(self.vertical);
params["placeholder"] = json!(cx.placeholder_text);
out.push(Action::Exec("text.create".into(), params));
out.push(Action::Notify("text.editNew".into()));
out
}
}
/// 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`]).
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
}
/// 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<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).
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)
}
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![];
}
let s: String = s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect();
// An IME commit replaces its marked text (an empty commit just removes it).
let committing = self.preedit.is_some();
if committing && s.is_empty() {
return self.ime_preedit(cx, "", None);
}
if let Some(p) = self.preedit.take() {
self.anchor = p.range.start;
self.caret = p.range.end;
}
if s.is_empty() {
return vec![];
}
// Use Typographer's Quotes (Document Setup): a typed straight quote becomes the document's
// opening or closing quote, by the character before it.
let setup = &cx.doc.setup;
let s = if setup.typographers_quotes && matches!(s.as_str(), "\"" | "'") {
let text = self.plain(cx);
let mut prev = text.get(..self.caret.min(self.anchor)).and_then(|b| b.chars().next_back());
setup.quotes.apply(&s, &mut prev)
} else {
s
};
self.clicks = (None, 0);
// Pasting what we copied keeps its formatting (unless pasted text is kept plain).
let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect();
if !committing && s.len() > 1 && s == clip && !cx.paste_plain_text {
let runs = self.clipboard.clone();
return self.replace(cx, "", Some(runs));
}
self.replace(cx, &s, None)
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
// The IME owns the keys while it composes (the UI doesn't send them; this is a guard).
if self.preedit.is_some() {
return vec![];
}
let Some(t) = self.current(cx) else {
self.editing = None;
return vec![];
};
let text = t.plain_text();
let len = text.len();
self.caret = self.caret.min(len);
self.anchor = self.anchor.min(len);
let (a, b) = self.sel();
// 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
}
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.
fn after_command(&mut self, cx: &ToolContext) -> Vec<Action> {
let Some(id) = self.editing else { return vec![] };
let mut cur = Some(id).filter(|i| cx.doc.node(*i).is_some());
while let Some(c) = cur {
if cx.selection.contains(c) {
return vec![];
}
cur = cx.doc.parent_of(c);
}
self.finish(cx)
}
/// After `text.create` / `text.createInPath` the engine selects the new object; edit it.
fn notify(&mut self, cx: &ToolContext, what: &str) {
if what == "text.editNew"
&& let Some(id) = cx.selection.objects.first().copied()
{
self.start_editing(id, 0);
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;
}
"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 }
}
}
#[cfg(test)]
#[path = "text_tests.rs"]
mod tests;