mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Type: the Touch Type tool moves, scales and rotates single characters
The toolbar listed the Touch Type tool (Shift+T), but choosing it did nothing. The reference app's Touch Type tool moves, scales and rotates one character of live type by dragging the handles of a box around it. A click with the tool now selects one character and shows a box around it. Dragging the box or its lower-left handle moves the character along its line and up or down. The upper-left handle scales it vertically, the lower-right handle scales it horizontally, the upper-right handle scales it proportionally, and the upper-middle handle rotates it. A character rotated by up to about 84 degrees follows the pointer too. Near a quarter turn, a drag moves it only along its line. Escape cancels a drag, and otherwise lets go of the character. Each drag is one text.touchType command and one undo step. It sets the character's baseline shift, scales and rotation, and moves it along its line by changing its own kerning and the kerning of the character before it, within the kerning range. The characters after it stay where they are. Where the font kerned the character with its neighbors, the two kerning values also keep the character in place when it is only raised, scaled or rotated. The characters after it stay where they were, or move by the change in its width when it is scaled. The first character of a line and a right-to-left character move only up and down. The type stays editable. Docs: docs/mcp.md, docs/target-app-parity.md, docs/type-parity.md.
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
use vectorcraft_text::edit;
|
||||
|
||||
use super::stroke::StrokeChange;
|
||||
@@ -31,6 +31,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
cmd!(
|
||||
"text.touchType",
|
||||
"Touch Type",
|
||||
[],
|
||||
None,
|
||||
"{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first character of a line and for a right-to-left character; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, along its own up axis when it is rotated); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)",
|
||||
has_doc,
|
||||
touch_type
|
||||
),
|
||||
cmd!(
|
||||
query "text.getRange",
|
||||
"Get Text Range",
|
||||
@@ -550,6 +559,185 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id.0, "runs": n}))
|
||||
}
|
||||
|
||||
/// The em a laid-out glyph's kerning and tracking are measured in, in text space: the size it
|
||||
/// was shaped at (superscript, small caps and Shrink Text to Fit included).
|
||||
fn glyph_em(t: &TextObject, g: &vectorcraft_text::PositionedGlyph) -> Option<f64> {
|
||||
let face = vectorcraft_text::FontDb::global().face_by_id(g.font_id)?;
|
||||
let h_scale = edit::slice_runs(&t.runs, g.byte, g.byte + g.len).first()?.style.h_scale / 100.0;
|
||||
let [a, b, ..] = g.xf.as_coeffs();
|
||||
Some(a.hypot(b) * face.units_per_em() / h_scale).filter(|em| em.is_finite() && *em > 0.0)
|
||||
}
|
||||
|
||||
fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.touchType";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?;
|
||||
if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad(C, "nothing to change: give dx, dy, hScale, vScale or rotation"));
|
||||
}
|
||||
let num = |k: &str| match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(0.0),
|
||||
Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`{k}` must be a number"))),
|
||||
};
|
||||
let (dx, dy) = (num("dx")?, num("dy")?);
|
||||
let look = json!({"hScale": p.get("hScale"), "vScale": p.get("vScale"), "rotation": p.get("rotation")});
|
||||
let mut change = CharChange::parse(&look, C, &s.doc()?.doc.setup)?;
|
||||
let t = text_ref(s, id)?;
|
||||
change.localize(&t, C)?;
|
||||
let points = t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points;
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let g = lay.glyphs.iter().find(|g| g.byte <= start && start < g.byte + g.len).ok_or_else(|| bad(C, format!("no character at byte {start}")))?;
|
||||
let (a, b) = (g.byte, g.byte + g.len);
|
||||
// The line's direction in reading order.
|
||||
let d = Vec2::new(g.angle.cos(), g.angle.sin());
|
||||
let along = if g.rtl { -d } else { d };
|
||||
let linear = |m: Affine, v: Vec2| m * (Point::ZERO + v) - m * Point::ZERO;
|
||||
// Document points per text-space point along `v`.
|
||||
let doc_len = |v: Vec2| linear(t.xf, v).hypot() / v.hypot();
|
||||
let limit = 1296.0 / points;
|
||||
if dx != 0.0 && g.rtl {
|
||||
return Err(bad(C, "a right-to-left character moves only up and down (dy)"));
|
||||
}
|
||||
let prev = lay.glyphs.iter().find(|h| h.line == g.line && h.byte + h.len == a);
|
||||
let pr = prev.map(|h| (h.byte, h.byte + h.len));
|
||||
let em = glyph_em(&t, g).ok_or_else(|| bad(C, "the character's size can't be measured"))?;
|
||||
let em_prev = match prev {
|
||||
Some(h) => Some(glyph_em(&t, h).ok_or_else(|| bad(C, "the character's size can't be measured"))?),
|
||||
None if dx != 0.0 => return Err(bad(C, "the first character of a line has no character before it to kern: it moves only up and down (dy)")),
|
||||
None => None,
|
||||
};
|
||||
let kerning_in = |(x, y): (usize, usize)| edit::slice_runs(&t.runs, x, y).first().and_then(|r| r.style.kerning).unwrap_or(0.0);
|
||||
// The move along the line, in text space, limited to what kerning from -1000 to 10000 (1/1000
|
||||
// em) on the character before it and on the character itself can give.
|
||||
let mut moved = dx / doc_len(along);
|
||||
if let (Some(range), Some(ep)) = (pr, em_prev) {
|
||||
let (kp, kc) = (kerning_in(range), kerning_in((a, b)));
|
||||
let hi = ((10000.0 - kp) * ep).min((kc + 1000.0) * em) / 1000.0;
|
||||
let lo = ((-1000.0 - kp) * ep).max((kc - 10000.0) * em) / 1000.0;
|
||||
if lo <= hi {
|
||||
moved = moved.clamp(lo, hi);
|
||||
}
|
||||
}
|
||||
// The character's scales, rotation and baseline shift, and kerning added to the character
|
||||
// before it (`before`) and to the character itself (`own`), in 1/1000 em.
|
||||
let touch = |t: &mut TextObject, before: Option<f64>, own: Option<f64>, raise: f64| {
|
||||
for (range, by) in [(pr, before), (Some((a, b)), own)] {
|
||||
if let (Some((x, y)), Some(by)) = (range, by) {
|
||||
edit::style_range(&mut t.runs, x, y, |st| st.kerning = Some((st.kerning.unwrap_or(0.0) + by).clamp(-1000.0, 10000.0)));
|
||||
}
|
||||
}
|
||||
edit::style_range(&mut t.runs, a, b, |st| {
|
||||
if raise != 0.0 {
|
||||
st.baseline_shift = (st.baseline_shift + raise).clamp(-limit, limit);
|
||||
}
|
||||
change.apply(st);
|
||||
});
|
||||
};
|
||||
// The baseline shift that raises the character `dy` document points. Baseline shift moves it
|
||||
// along its own up axis, turned with its rotation (the new one, when the call sets it), by a
|
||||
// length that the object transform, Shrink Text to Fit and a path effect such as Skew scale:
|
||||
// two layouts with the call's other changes and shifts one step apart, both within the range,
|
||||
// measure that length. Neither shift is one that a run already has, so the character's run
|
||||
// splits off alike in both.
|
||||
let raise = if dy == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
let shift = edit::slice_runs(&t.runs, a, b).first().map_or(0.0, |r| r.style.baseline_shift);
|
||||
let from = shift.clamp(-limit, limit);
|
||||
let free = |v: f64| v.abs() <= limit && t.runs.iter().all(|r| r.style.baseline_shift != v);
|
||||
// A step of a quarter of the range at most leaves room for two steps up or down.
|
||||
let unit = (limit / 4.0).min(1.0);
|
||||
let step =
|
||||
[1.0, -1.0, 0.75, -0.75].into_iter().map(|f| f * unit).find(|p| free(from + p) && free(from + 2.0 * p)).unwrap_or(-unit.copysign(from));
|
||||
// The character's pen with its baseline shift set to `to`.
|
||||
let pen_at = |to: f64| {
|
||||
let mut trial = t.clone();
|
||||
touch(&mut trial, None, None, to - shift);
|
||||
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial);
|
||||
l.glyphs.iter().find(|h| h.byte == a).map(|h| h.xf * Point::ZERO)
|
||||
};
|
||||
let per_step = match (pen_at(from + step), pen_at(from + 2.0 * step)) {
|
||||
(Some(p), Some(q)) => linear(t.xf, q - p).hypot() / step.abs(),
|
||||
_ => 0.0,
|
||||
};
|
||||
dy / per_step
|
||||
};
|
||||
if !raise.is_finite() {
|
||||
return Err(bad(C, "the character's size can't be measured"));
|
||||
}
|
||||
// Where a glyph starts along the line.
|
||||
let pen = |h: &vectorcraft_text::PositionedGlyph| h.origin.to_vec2().dot(along);
|
||||
// How much further the character's new scales advance it along the line: the character laid
|
||||
// out alone, in point type of the same orientation, before and after the change.
|
||||
let grown = {
|
||||
let mut alone = TextObject { kind: TextKind::Point, runs: edit::slice_runs(&t.runs, a, b), paras: Vec::new(), ..t.clone() };
|
||||
let advance = |o: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), o).glyphs.iter().map(|h| h.advance).sum::<f64>();
|
||||
let old = advance(&alone);
|
||||
edit::style_range(&mut alone.runs, 0, b - a, |st| change.apply(st));
|
||||
(advance(&alone) - old) * lay.fit_scale
|
||||
};
|
||||
// The character lands `moved` further on, and the character after it on its line keeps its
|
||||
// distance from it, wider or narrower only by the character's new scales.
|
||||
let goal = pen(g) + moved;
|
||||
let next_goal = lay.glyphs.iter().find(|h| h.byte == b && h.line == g.line).map(|h| pen(h) + grown);
|
||||
// How far the character and the character after it on its line miss their places, along the
|
||||
// line, with the kerning `k` (before, own) added.
|
||||
let miss = |k: (Option<f64>, Option<f64>)| {
|
||||
let mut trial = t.clone();
|
||||
touch(&mut trial, k.0, k.1, raise);
|
||||
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial);
|
||||
let h = l.glyphs.iter().find(|h| h.byte == a)?;
|
||||
let next = match (l.glyphs.iter().find(|n| n.byte == b && n.line == h.line), next_goal) {
|
||||
(Some(n), Some(at)) => pen(n) - at,
|
||||
_ => 0.0,
|
||||
};
|
||||
Some((pen(h) - goal, next))
|
||||
};
|
||||
let placed = |m: (f64, f64)| m.0.abs() <= 1e-9 && m.1.abs() <= 1e-9;
|
||||
let mut k = if moved != 0.0 { (em_prev.map(|ep| moved / ep * 1000.0), Some(-moved / em * 1000.0)) } else { (None, None) };
|
||||
// A manual kerning value turns off the font's kerning for its character, each style run is
|
||||
// shaped apart, and centered, right-aligned and justified lines move as their width changes, so
|
||||
// the first values can miss. Layouts then measure what each kerning value does to the two
|
||||
// characters, and the values are solved for from that (at most six layouts).
|
||||
if !g.rtl && miss(k).is_some_and(|m| !placed(m)) {
|
||||
k = (pr.map(|_| k.0.unwrap_or(0.0)), Some(k.1.unwrap_or(0.0)));
|
||||
if let Some(mut m) = miss(k) {
|
||||
const STEP: f64 = 10.0;
|
||||
let column = |dk: (f64, f64)| miss((k.0.map(|v| v + dk.0), k.1.map(|v| v + dk.1))).map(|n| ((n.0 - m.0) / STEP, (n.1 - m.1) / STEP));
|
||||
let by_before = if k.0.is_some() { column((STEP, 0.0)) } else { None };
|
||||
let by_own = column((0.0, STEP));
|
||||
// The kerning changes that cancel the misses `m`.
|
||||
let solve = |m: (f64, f64)| match (by_before, by_own) {
|
||||
(Some(p), Some(q)) if (p.0 * q.1 - q.0 * p.1).abs() > 1e-12 => {
|
||||
let det = p.0 * q.1 - q.0 * p.1;
|
||||
((q.0 * m.1 - m.0 * q.1) / det, (m.0 * p.1 - p.0 * m.1) / det)
|
||||
}
|
||||
(Some(p), _) if p.0.abs() > 1e-12 => (-m.0 / p.0, 0.0),
|
||||
(_, Some(q)) if q.1.abs() > 1e-12 => (0.0, -m.1 / q.1),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
for _ in 0..2 {
|
||||
if placed(m) {
|
||||
break;
|
||||
}
|
||||
let (dp, dq) = solve(m);
|
||||
k = (k.0.map(|v| v + dp), k.1.map(|v| v + dq));
|
||||
match miss(k) {
|
||||
Some(n) => m = n,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.edit("Touch Type", |d, _| {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
touch(t, k.0, k.1, raise);
|
||||
refresh_bounds(t);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"id": id.0, "start": a, "end": b}))
|
||||
}
|
||||
|
||||
fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.createInPath";
|
||||
let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?;
|
||||
|
||||
@@ -98,6 +98,269 @@ fn set_range_style_defaults_to_all_text_and_get_range() {
|
||||
assert_eq!(r["length"], json!(3));
|
||||
}
|
||||
|
||||
/// Where each glyph of type object `id` is drawn from (its origin in font units), document space.
|
||||
fn glyph_pens(s: &Session, id: NodeId) -> Vec<(usize, Point)> {
|
||||
let t = obj(s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
lay.glyphs.iter().map(|g| (g.byte, t.xf * (g.xf * Point::ZERO))).collect()
|
||||
}
|
||||
|
||||
fn moved_by(before: &[(usize, Point)], after: &[(usize, Point)], byte: usize) -> (f64, f64) {
|
||||
let at = |v: &[(usize, Point)]| v.iter().find(|(b, _)| *b == byte).map(|(_, p)| *p).unwrap_or_else(|| panic!("no glyph at byte {byte}"));
|
||||
let d = at(after) - at(before);
|
||||
(d.x, d.y)
|
||||
}
|
||||
|
||||
fn near(a: (f64, f64), b: (f64, f64)) -> bool {
|
||||
(a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_type_moves_one_character_and_leaves_the_others_where_they_are() {
|
||||
let mut s = session();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = glyph_pens(&s, id);
|
||||
let r = s.execute("text.touchType", &json!({"id": id.0, "start": 1, "dx": 10, "dy": 4})).unwrap();
|
||||
assert_eq!((r["start"].clone(), r["end"].clone()), (json!(1), json!(2)));
|
||||
let after = glyph_pens(&s, id);
|
||||
assert!(near(moved_by(&before, &after, 1), (10.0, -4.0)), "the y moves 10 pt right and 4 pt up: {:?}", moved_by(&before, &after, 1));
|
||||
for byte in [0, 2, 3] {
|
||||
assert!(near(moved_by(&before, &after, byte), (0.0, 0.0)), "byte {byte} stays: {:?}", moved_by(&before, &after, byte));
|
||||
}
|
||||
// Kerning on the T and the y, and a baseline shift on the y: live attributes, one undo step.
|
||||
let t = obj(&s, id);
|
||||
let style = |byte: usize| vectorcraft_text::edit::slice_runs(&t.runs, byte, byte + 1)[0].style.clone();
|
||||
let (k0, k1) = (style(0).kerning.unwrap(), style(1).kerning.unwrap());
|
||||
assert!(k0 > 0.0 && k1 < 0.0, "{k0} {k1}");
|
||||
assert!((style(1).baseline_shift - 4.0).abs() < 1e-9 && style(2).baseline_shift == 0.0);
|
||||
assert_eq!(undo_labels(&s).iter().filter(|l| *l == "Touch Type").count(), 1);
|
||||
// The type stays editable, and the moved character keeps its attributes.
|
||||
s.execute("text.editRange", &json!({"id": id.0, "start": 4, "end": 4, "insert": "!"})).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "Type!");
|
||||
assert!(near(moved_by(&before, &glyph_pens(&s, id), 1), (10.0, -4.0)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(moved_by(&before, &glyph_pens(&s, id), 1), (0.0, 0.0)), "undone");
|
||||
// The first character of a line moves only up and down.
|
||||
assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dx": 5})).is_err());
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": -2})).unwrap();
|
||||
assert!(near(moved_by(&before, &glyph_pens(&s, id), 0), (0.0, 2.0)));
|
||||
assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 1})).is_err(), "nothing to change");
|
||||
assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 9, "dy": 1})).is_err(), "no character there");
|
||||
}
|
||||
|
||||
/// Raising, scaling or rotating a character leaves it and the characters after it where they
|
||||
/// were along the line, also where the font kerns it with its neighbors (the T and the y).
|
||||
#[test]
|
||||
fn touch_type_keeps_the_kerning_of_a_raised_or_scaled_character() {
|
||||
let mut s = session();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = glyph_pens(&s, id);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "dy": 5})).unwrap();
|
||||
let after = glyph_pens(&s, id);
|
||||
assert!(near(moved_by(&before, &after, 1), (0.0, -5.0)), "{:?}", moved_by(&before, &after, 1));
|
||||
for byte in [0, 2, 3] {
|
||||
assert!(near(moved_by(&before, &after, byte), (0.0, 0.0)), "byte {byte}: {:?}", moved_by(&before, &after, byte));
|
||||
}
|
||||
// A wider y pushes the p and e on by what it widens, and no further.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let width = |s: &Session| {
|
||||
let t = obj(s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap();
|
||||
let face = vectorcraft_text::FontDb::global().face_by_id(y.font_id).unwrap();
|
||||
let [a, b, ..] = y.xf.as_coeffs();
|
||||
face.advance(y.gid) * a.hypot(b)
|
||||
};
|
||||
let w0 = width(&s);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "hScale": 150})).unwrap();
|
||||
let after = glyph_pens(&s, id);
|
||||
assert!(near(moved_by(&before, &after, 1), (0.0, 0.0)), "{:?}", moved_by(&before, &after, 1));
|
||||
let wider = width(&s) - w0;
|
||||
assert!((wider - w0 / 2.0).abs() < 1e-6, "{wider} {w0}");
|
||||
for byte in [2, 3] {
|
||||
assert!(near(moved_by(&before, &after, byte), (wider, 0.0)), "byte {byte}: {:?}", moved_by(&before, &after, byte));
|
||||
}
|
||||
// A move is limited to what kerning can give (-1000/1000 em on the y): the p does not move
|
||||
// by more than the font's kerning pair with the y.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "dx": 80})).unwrap();
|
||||
let after = glyph_pens(&s, id);
|
||||
let (y, _) = moved_by(&before, &after, 1);
|
||||
assert!((39.0..=41.0).contains(&y), "the y stops at about 40 pt (1 em): {y}");
|
||||
assert!(moved_by(&before, &after, 2).0.abs() < 1.0, "{:?}", moved_by(&before, &after, 2));
|
||||
}
|
||||
|
||||
/// A character drawn with two glyphs (ß in All Caps is SS) widens by both when it is scaled.
|
||||
#[test]
|
||||
fn touch_type_scales_a_character_drawn_with_two_glyphs() {
|
||||
let mut s = session();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "aße", "size": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "allCaps": true})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let esses: Vec<_> = lay.glyphs.iter().filter(|g| g.byte == 1).collect();
|
||||
assert_eq!(esses.len(), 2, "ß in All Caps is two glyphs");
|
||||
let width: f64 = esses
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let face = vectorcraft_text::FontDb::global().face_by_id(g.font_id).unwrap();
|
||||
let [a, b, ..] = g.xf.as_coeffs();
|
||||
face.advance(g.gid) * a.hypot(b)
|
||||
})
|
||||
.sum();
|
||||
let before = glyph_pens(&s, id);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "hScale": 150})).unwrap();
|
||||
// Half the width of SS (the font's kerning between the two S glyphs scales with them).
|
||||
let (x, _) = moved_by(&before, &glyph_pens(&s, id), 3);
|
||||
assert!((x - width / 2.0).abs() < 1.0, "the E moves on by half the width of SS: {x} {width}");
|
||||
}
|
||||
|
||||
/// In a centered or right-aligned line, the kerning still moves only the dragged character.
|
||||
#[test]
|
||||
fn touch_type_moves_one_character_in_centered_and_right_aligned_lines() {
|
||||
for justify in ["center", "right"] {
|
||||
let mut s = session();
|
||||
let r = s.execute("text.create", &json!({"x": 200, "y": 100, "text": "Type", "size": 40, "justify": justify})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let before = glyph_pens(&s, id);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "dx": 10, "dy": 4})).unwrap();
|
||||
let after = glyph_pens(&s, id);
|
||||
assert!(near(moved_by(&before, &after, 1), (10.0, -4.0)), "{justify}: {:?}", moved_by(&before, &after, 1));
|
||||
for byte in [2, 3] {
|
||||
assert!(near(moved_by(&before, &after, byte), (0.0, 0.0)), "{justify}, byte {byte}: {:?}", moved_by(&before, &after, byte));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `dy` moves a rotated character that many document points along its own up axis, also in type
|
||||
/// scaled unevenly.
|
||||
#[test]
|
||||
fn touch_type_raises_a_rotated_character_in_unevenly_scaled_type() {
|
||||
let mut s = session();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("object.transform", &json!({"ids": [id.0], "matrix": [2, 0, 0, 1, 0, 0]})).unwrap();
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "rotation": 90})).unwrap();
|
||||
let before = glyph_pens(&s, id);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "dy": 10})).unwrap();
|
||||
let (x, y) = moved_by(&before, &glyph_pens(&s, id), 1);
|
||||
assert!(((x * x + y * y).sqrt() - 10.0).abs() < 1e-6, "({x}, {y})");
|
||||
}
|
||||
|
||||
/// `dy` moves a character that many document points in type scaled up 2000 times, where baseline
|
||||
/// shift is limited to 0.648 pt of text space.
|
||||
#[test]
|
||||
fn touch_type_raises_a_character_in_type_scaled_up_2000_times() {
|
||||
let mut s = session();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 0, "y": 0, "text": "Type", "size": 1})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("object.transform", &json!({"ids": [id.0], "matrix": [2000, 0, 0, 2000, 0, 0]})).unwrap();
|
||||
let before = glyph_pens(&s, id);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "dy": 10})).unwrap();
|
||||
let (x, y) = moved_by(&before, &glyph_pens(&s, id), 1);
|
||||
assert!(near((x, y), (0.0, -10.0)), "({x}, {y})");
|
||||
}
|
||||
|
||||
/// A character whose baseline shift is beyond the range that the object's scale leaves keeps its
|
||||
/// shift through a change that does not raise it, and a `dy` moves it back into the range.
|
||||
#[test]
|
||||
fn touch_type_moves_a_character_shifted_beyond_the_range() {
|
||||
let mut s = session();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "baselineShift": 1000})).unwrap();
|
||||
s.execute("object.transform", &json!({"ids": [id.0], "matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
|
||||
let before = glyph_pens(&s, id);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "hScale": 150})).unwrap();
|
||||
let (_, up) = moved_by(&before, &glyph_pens(&s, id), 1);
|
||||
assert!(up.abs() < 1e-6, "the shift stays: {up}");
|
||||
let before = glyph_pens(&s, id);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "dy": -10})).unwrap();
|
||||
let (_, down) = moved_by(&before, &glyph_pens(&s, id), 1);
|
||||
assert!(down >= 10.0 - 1e-6, "{down}");
|
||||
}
|
||||
|
||||
/// `dy` moves a rotated character on a path with the Skew effect that many document points too,
|
||||
/// where the effect stretches its baseline shift.
|
||||
#[test]
|
||||
fn touch_type_raises_a_rotated_character_on_a_skewed_path() {
|
||||
let mut s = session();
|
||||
let pid = s.execute("path.create", &json!({"d": "M100 400 L400 100"})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "Type", "size": 40, "at": [100, 400]})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("type.pathOptions", &json!({"effect": "skew"})).unwrap();
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "rotation": 45})).unwrap();
|
||||
let before = glyph_pens(&s, id);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "dy": 10})).unwrap();
|
||||
let (x, y) = moved_by(&before, &glyph_pens(&s, id), 1);
|
||||
assert!(((x * x + y * y).sqrt() - 10.0).abs() < 1e-6, "({x}, {y})");
|
||||
}
|
||||
|
||||
/// A right-to-left character moves only up and down.
|
||||
#[test]
|
||||
fn touch_type_moves_a_right_to_left_character_only_up_and_down() {
|
||||
let mut s = session();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "אבג", "size": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 2, "dx": 5})).is_err());
|
||||
let before = glyph_pens(&s, id);
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 2, "dy": 3})).unwrap();
|
||||
let (_, up) = moved_by(&before, &glyph_pens(&s, id), 2);
|
||||
assert!((up + 3.0).abs() < 1e-9, "{up}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_type_scales_and_rotates_one_character() {
|
||||
let mut s = session();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 2, "hScale": 150, "vScale": 50, "rotation": 30})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
let st = |byte: usize| vectorcraft_text::edit::slice_runs(&t.runs, byte, byte + 1)[0].style.clone();
|
||||
assert_eq!((st(2).h_scale, st(2).v_scale, st(2).rotation), (150.0, 50.0, 30.0));
|
||||
for byte in [1, 3] {
|
||||
assert_eq!((st(byte).h_scale, st(byte).v_scale, st(byte).rotation), (100.0, 100.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Touch Type tool through the session: a click on a character selects it, and dragging its
|
||||
/// box sends `text.touchType` as one undo step.
|
||||
#[test]
|
||||
fn the_touch_type_tool_drags_a_character() {
|
||||
let mut s = session();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
let before = glyph_pens(&s, id);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("touchType", v).unwrap();
|
||||
let t = obj(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap();
|
||||
let at = t.xf * (y.origin + vectorcraft_geom::Vec2::new(y.advance / 2.0, -10.0));
|
||||
click(&mut s, at.x, at.y);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id], "the click selects the type");
|
||||
assert_eq!(s.tool_options(), json!({"id": id.0, "start": 1}));
|
||||
for (k, x, y) in [(PointerKind::Down, at.x, at.y), (PointerKind::Drag, at.x + 10.0, at.y - 4.0), (PointerKind::Up, at.x + 10.0, at.y - 4.0)] {
|
||||
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
let after = glyph_pens(&s, id);
|
||||
assert!(near(moved_by(&before, &after, 1), (10.0, -4.0)), "{:?}", moved_by(&before, &after, 1));
|
||||
assert!(near(moved_by(&before, &after, 2), (0.0, 0.0)));
|
||||
assert_eq!(undo_labels(&s).iter().filter(|l| *l == "Touch Type").count(), 1);
|
||||
// Turned 30°, the y still follows the pointer: an upward drag of its box moves it straight up.
|
||||
s.execute("text.touchType", &json!({"id": id.0, "start": 1, "rotation": 30})).unwrap();
|
||||
let before = glyph_pens(&s, id);
|
||||
let t = obj(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap();
|
||||
let face = vectorcraft_text::FontDb::global().face_by_id(y.font_id).unwrap();
|
||||
let (ascent, descent) = face.vertical_metrics();
|
||||
let at = t.xf * (y.xf * Point::new(face.advance(y.gid) / 2.0, (descent - ascent) / 2.0));
|
||||
for (k, x, y) in [(PointerKind::Down, at.x, at.y), (PointerKind::Drag, at.x, at.y - 4.0), (PointerKind::Up, at.x, at.y - 4.0)] {
|
||||
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
let after = glyph_pens(&s, id);
|
||||
assert!(near(moved_by(&before, &after, 1), (0.0, -4.0)), "{:?}", moved_by(&before, &after, 1));
|
||||
assert!(near(moved_by(&before, &after, 2), (0.0, 0.0)), "{:?}", moved_by(&before, &after, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_typing_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -741,7 +741,8 @@ fn mapped_bounds(n: &Node, hi: &Homography) -> Option<Rect> {
|
||||
if ok { acc } else { None }
|
||||
}
|
||||
|
||||
fn in_quad(q: &[Point; 4], p: Point) -> bool {
|
||||
/// Is `p` inside the convex quad `q` (either winding; a point on an edge is inside)?
|
||||
pub(crate) fn in_quad(q: &[Point; 4], p: Point) -> bool {
|
||||
let mut sign = 0.0;
|
||||
for i in 0..4 {
|
||||
let (a, b) = (q[i], q[(i + 1) % 4]);
|
||||
|
||||
@@ -36,6 +36,7 @@ pub mod shaperwidget;
|
||||
pub mod slice;
|
||||
pub mod symbolism;
|
||||
pub mod text;
|
||||
pub mod touchtype;
|
||||
pub mod typewidget;
|
||||
pub mod xform;
|
||||
|
||||
@@ -768,6 +769,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
|
||||
.or_else(|| cropimage::create(other))
|
||||
.or_else(|| cut::create(other))
|
||||
.or_else(|| intertwine::create(other))
|
||||
.or_else(|| touchtype::create(other))
|
||||
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
//! The Touch Type tool (Shift+T): a click on a character of a type object selects that one
|
||||
//! character and shows a box around it. Dragging the box, or its lower-left handle, moves the
|
||||
//! character along its line and up or down. The upper-left handle scales it vertically, the
|
||||
//! lower-right handle scales it horizontally, the upper-right handle scales it proportionally, and
|
||||
//! the handle in the middle of the top edge rotates it. Every drag previews one `text.touchType`
|
||||
//! (kerning, baseline shift, horizontal and vertical scale, character rotation) and commits it as
|
||||
//! one undo step; the type stays editable. Escape lets go of the character. Behavior:
|
||||
//! <https://web.archive.org/web/20261007185617/https://helpx.adobe.com/illustrator/using/tool-techniques/touch-type-tool.html>
|
||||
//! and <https://web.archive.org/web/20260926095922/https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/add-text-illustrator-text-tools.html>.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::bez::unit;
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
use vectorcraft_text::{FontDb, PositionedGlyph, TextLayout, edit};
|
||||
|
||||
use crate::distort::perspective::in_quad;
|
||||
use crate::xform::BLUE;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// The tool's id.
|
||||
pub const ID: &str = "touchType";
|
||||
|
||||
/// Create the Touch Type tool by id (None = not ours).
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
(id == ID).then(|| Box::new(TouchTypeTool::default()) as Box<dyn Tool>)
|
||||
}
|
||||
|
||||
/// What a drag changes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Part {
|
||||
/// The box or its lower-left handle: kerning and baseline shift.
|
||||
Move,
|
||||
/// The upper-left handle: vertical scale.
|
||||
VScale,
|
||||
/// The lower-right handle: horizontal scale.
|
||||
HScale,
|
||||
/// The upper-right handle: both scales alike.
|
||||
Scale,
|
||||
/// The handle in the middle of the top edge: character rotation.
|
||||
Rotate,
|
||||
}
|
||||
|
||||
/// A character's box, in text space.
|
||||
#[derive(Clone, Debug)]
|
||||
struct CharBox {
|
||||
/// The character's (its cluster's) first byte in the plain text.
|
||||
start: usize,
|
||||
/// Its box: the boxes of its glyphs ([`glyph_quad`]) together.
|
||||
quad: [Point; 4],
|
||||
/// The glyph's origin on its shifted baseline, which the scale handles measure from.
|
||||
pen: Point,
|
||||
/// Its pen position on the line's baseline, which character rotation turns it about.
|
||||
pivot: Point,
|
||||
/// A unit vector along its line in reading order.
|
||||
along: Vec2,
|
||||
/// The glyph's own x axis and its up axis (with its rotation, which baseline shift moves it
|
||||
/// along), unit vectors.
|
||||
ex: Vec2,
|
||||
ey: Vec2,
|
||||
/// Whether the tool moves it along its line by kerning: a character comes before it on its
|
||||
/// line and it is not right-to-left.
|
||||
kerned: bool,
|
||||
/// Its style as the Character panel shows it (horizontal scale with the object transform).
|
||||
style: CharStyle,
|
||||
}
|
||||
|
||||
impl CharBox {
|
||||
/// The handle positions, text space.
|
||||
fn handle(&self, part: Part) -> Point {
|
||||
let [tl, tr, br, bl] = self.quad;
|
||||
match part {
|
||||
Part::Move => bl,
|
||||
Part::VScale => tl,
|
||||
Part::HScale => br,
|
||||
Part::Scale => tr,
|
||||
Part::Rotate => tl.midpoint(tr),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PARTS: [Part; 5] = [Part::Rotate, Part::VScale, Part::Scale, Part::HScale, Part::Move];
|
||||
|
||||
/// A drag in progress.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Drag {
|
||||
part: Part,
|
||||
/// Where the button went down (document space).
|
||||
press: Point,
|
||||
/// The character as it was then.
|
||||
from: CharBox,
|
||||
/// A preview has been sent (the interaction began).
|
||||
began: bool,
|
||||
}
|
||||
|
||||
/// The type object's layout, keyed by the font database's generation and the object.
|
||||
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TouchTypeTool {
|
||||
/// The selected character: its type object and its first byte.
|
||||
target: Option<(NodeId, usize)>,
|
||||
drag: Option<Drag>,
|
||||
cache: Mutex<Option<CachedLayout>>,
|
||||
}
|
||||
|
||||
fn text_of<'a>(cx: &'a ToolContext, id: NodeId) -> Option<&'a TextObject> {
|
||||
match &cx.doc.node(id)?.kind {
|
||||
NodeKind::Text(t) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The linear part of `m` applied to `v`.
|
||||
fn linear(m: Affine, v: Vec2) -> Vec2 {
|
||||
m * (Point::ZERO + v) - m * Point::ZERO
|
||||
}
|
||||
|
||||
/// `to / from`, at least 0.01: what a scale handle dragged from `from` to `to` multiplies the scale
|
||||
/// by (1 for a glyph with no width or height).
|
||||
fn factor(to: f64, from: f64) -> f64 {
|
||||
let f = to / from;
|
||||
if f.is_finite() { f.max(0.01) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Top left, top right, bottom right and bottom left of glyph `g`'s box in text space: its advance
|
||||
/// by its font's ascent and descent, scaled, shifted and turned with the glyph. None when its font
|
||||
/// is gone.
|
||||
fn glyph_quad(g: &PositionedGlyph) -> Option<[Point; 4]> {
|
||||
let face = FontDb::global().face_by_id(g.font_id)?;
|
||||
let (ascent, descent) = face.vertical_metrics();
|
||||
let advance = face.advance(g.gid);
|
||||
let m = g.xf;
|
||||
Some([m * Point::new(0.0, -ascent), m * Point::new(advance, -ascent), m * Point::new(advance, descent), m * Point::new(0.0, descent)])
|
||||
}
|
||||
|
||||
/// Is glyph `gi` of `lay` an inline graphic?
|
||||
fn is_inline(lay: &TextLayout, gi: usize) -> bool {
|
||||
lay.inlines.iter().any(|i| i.glyph == gi)
|
||||
}
|
||||
|
||||
/// The box of the character whose first glyph is glyph `gi` of `lay` (the layout of `t`): the box
|
||||
/// in that glyph's own frame around the boxes of all its glyphs (an All Caps ß is two); none for
|
||||
/// inline graphics, glyphs whose font is gone and type whose transform is collapsed.
|
||||
fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option<CharBox> {
|
||||
let g: &PositionedGlyph = lay.glyphs.get(gi)?;
|
||||
if is_inline(lay, gi) {
|
||||
return None;
|
||||
}
|
||||
let m = g.xf;
|
||||
let (pen, ex, ey) = (m * Point::ZERO, unit(linear(m, Vec2::new(1.0, 0.0))), unit(linear(m, Vec2::new(0.0, -1.0))));
|
||||
// The corners of its glyphs' boxes in the first glyph's font units (y down).
|
||||
let to_glyph = m.inverse();
|
||||
let corners: Vec<Point> =
|
||||
lay.glyphs.iter().filter(|h| h.byte == g.byte && h.line == g.line).filter_map(glyph_quad).flatten().map(|p| to_glyph * p).collect();
|
||||
let (mut x0, mut x1, mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
|
||||
for p in corners {
|
||||
(x0, x1, y0, y1) = (x0.min(p.x), x1.max(p.x), y0.min(p.y), y1.max(p.y));
|
||||
}
|
||||
if ![x0, x1, y0, y1].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let quad = [m * Point::new(x0, y0), m * Point::new(x1, y0), m * Point::new(x1, y1), m * Point::new(x0, y1)];
|
||||
let d = Vec2::new(g.angle.cos(), g.angle.sin());
|
||||
let style = edit::slice_runs(&t.runs, g.byte, g.byte + g.len).first().map(|r| r.style.clone())?;
|
||||
Some(CharBox {
|
||||
start: g.byte,
|
||||
quad,
|
||||
pen,
|
||||
pivot: g.origin,
|
||||
along: if g.rtl { -d } else { d },
|
||||
ex,
|
||||
ey,
|
||||
kerned: !g.rtl && lay.glyphs.iter().any(|h| h.line == g.line && h.byte + h.len == g.byte),
|
||||
style: t.effective_char_style(&style)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl TouchTypeTool {
|
||||
/// Layout of `t`, cached by content (overlays run every frame) and by the fonts available.
|
||||
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
|
||||
}
|
||||
|
||||
/// The selected character's type object and box, while that object is selected.
|
||||
fn current<'c>(&self, cx: &'c ToolContext) -> Option<(NodeId, &'c TextObject, CharBox)> {
|
||||
let (id, start) = self.target?;
|
||||
let selected = std::iter::successors(Some(id), |c| cx.doc.parent_of(*c)).any(|c| cx.selection.contains(c));
|
||||
let t = text_of(cx, id).filter(|_| selected)?;
|
||||
let lay = self.layout(t);
|
||||
let gi = lay.glyphs.iter().position(|g| g.byte <= start && start < g.byte + g.len)?;
|
||||
Some((id, t, char_box(t, &lay, gi)?))
|
||||
}
|
||||
|
||||
/// The character of a type object under `p` (document space): the one whose box holds it, else
|
||||
/// the nearest one in the object clicked.
|
||||
fn char_at(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, usize)> {
|
||||
let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { type_path_only: false, ..cx.hit_options() })?;
|
||||
let t = text_of(cx, h.leaf)?;
|
||||
let lay = self.layout(t);
|
||||
let local = t.xf.inverse() * p;
|
||||
let boxes: Vec<(usize, [Point; 4])> = (lay.glyphs.iter().enumerate())
|
||||
.filter(|(gi, g)| g.len > 0 && !is_inline(&lay, *gi))
|
||||
.filter_map(|(_, g)| Some((g.byte, glyph_quad(g)?)))
|
||||
.collect();
|
||||
let center = |q: &[Point; 4]| q[0].midpoint(q[2]);
|
||||
let hit = boxes.iter().rev().find(|(_, q)| in_quad(q, local));
|
||||
let best = hit.or_else(|| boxes.iter().min_by(|(_, a), (_, b)| (center(a) - local).hypot2().total_cmp(&(center(b) - local).hypot2())))?;
|
||||
Some((h.leaf, best.0))
|
||||
}
|
||||
|
||||
/// The handle of `b` (of an object drawn through `xf`) under `p`, document space.
|
||||
fn part_at(cx: &ToolContext, xf: Affine, b: &CharBox, p: Point) -> Option<Part> {
|
||||
let tol = cx.tol(5.0);
|
||||
if let Some(part) = PARTS.into_iter().find(|part| (xf * b.handle(*part) - p).hypot() <= tol) {
|
||||
return Some(part);
|
||||
}
|
||||
in_quad(&b.quad, xf.inverse() * p).then_some(Part::Move)
|
||||
}
|
||||
|
||||
/// The `text.touchType` params for the drag `d` with the pointer at `p` (document space).
|
||||
fn params(id: NodeId, t: &TextObject, d: &Drag, p: Point) -> Value {
|
||||
let b = &d.from;
|
||||
let inv = t.xf.inverse();
|
||||
let moved = linear(inv, p - d.press);
|
||||
let doc_len = |v: Vec2| linear(t.xf, v).hypot();
|
||||
let st = &b.style;
|
||||
let mut out = json!({"id": id.0, "start": b.start});
|
||||
match d.part {
|
||||
Part::Move => {
|
||||
// Kerning moves the character along its line, and baseline shift moves it along its
|
||||
// own up axis, which turns with its rotation. The drag is split between the two.
|
||||
// Turned nearly onto the line, the character moves only along the line.
|
||||
let det = b.along.cross(b.ey);
|
||||
let (along, rise) = if !b.kerned {
|
||||
(0.0, moved.dot(b.ey))
|
||||
} else if det.abs() > 0.1 {
|
||||
(moved.cross(b.ey) / det, b.along.cross(moved) / det)
|
||||
} else {
|
||||
(moved.dot(b.along), 0.0)
|
||||
};
|
||||
out["dx"] = json!(along * doc_len(b.along));
|
||||
out["dy"] = json!(rise * doc_len(b.ey));
|
||||
}
|
||||
Part::HScale => {
|
||||
let w = (b.quad[1] - b.quad[0]).hypot();
|
||||
out["hScale"] = json!(st.h_scale * factor(w + moved.dot(b.ex), w));
|
||||
}
|
||||
Part::VScale => {
|
||||
let h = (b.quad[0] - b.pen).dot(b.ey);
|
||||
out["vScale"] = json!(st.v_scale * factor(h + moved.dot(b.ey), h));
|
||||
}
|
||||
Part::Scale => {
|
||||
let v = b.quad[1] - b.pen;
|
||||
let s = factor((v + moved).dot(v), v.hypot2());
|
||||
out["hScale"] = json!(st.h_scale * s);
|
||||
out["vScale"] = json!(st.v_scale * s);
|
||||
}
|
||||
Part::Rotate => {
|
||||
let (from, to) = (inv * d.press - b.pivot, inv * p - b.pivot);
|
||||
// Text space has y down, so a growing angle turns clockwise; rotation counts
|
||||
// counter-clockwise.
|
||||
let turned = (to.y.atan2(to.x) - from.y.atan2(from.x)).to_degrees();
|
||||
out["rotation"] = json!(st.rotation - turned);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for TouchTypeTool {
|
||||
fn id(&self) -> &'static str {
|
||||
ID
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
let mut out = vec![];
|
||||
let on_box = self.current(cx).and_then(|(_, t, b)| Some((Self::part_at(cx, t.xf, &b, ev.pos)?, b)));
|
||||
let (part, from) = match on_box {
|
||||
Some(hit) => hit,
|
||||
None => {
|
||||
// A click on another character selects it (and its type object).
|
||||
self.target = self.char_at(cx, ev.pos);
|
||||
let Some((id, _)) = self.target else { return out };
|
||||
if !cx.selection.contains(id) {
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [id.0]})));
|
||||
}
|
||||
let Some(t) = text_of(cx, id) else { return out };
|
||||
let lay = self.layout(t);
|
||||
let start = self.target.map_or(0, |(_, s)| s);
|
||||
let Some(b) = lay.glyphs.iter().position(|g| g.byte == start).and_then(|gi| char_box(t, &lay, gi)) else { return out };
|
||||
(Part::Move, b)
|
||||
}
|
||||
};
|
||||
self.drag = Some(Drag { part, press: ev.pos, from, began: false });
|
||||
out
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
let (Some((id, _)), Some(d)) = (self.target, self.drag.as_mut()) else { return vec![] };
|
||||
let Some(t) = text_of(cx, id) else { return vec![] };
|
||||
if !d.began && (ev.pos - d.press).hypot() < cx.tol(1.0) {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
if !d.began {
|
||||
d.began = true;
|
||||
out.push(Action::Begin("Touch Type".into()));
|
||||
}
|
||||
out.push(Action::Preview("text.touchType".into(), Self::params(id, t, d, ev.pos)));
|
||||
out
|
||||
}
|
||||
PointerKind::Up => match self.drag.take() {
|
||||
Some(d) if d.began => vec![Action::Commit],
|
||||
_ => vec![],
|
||||
},
|
||||
PointerKind::DoubleClick | PointerKind::Move => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape cancels a drag, and otherwise lets go of the selected character.
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key != ToolKey::Escape {
|
||||
return vec![];
|
||||
}
|
||||
match self.drag.take() {
|
||||
Some(d) if d.began => vec![Action::Cancel],
|
||||
Some(_) => vec![],
|
||||
None => {
|
||||
self.target = None;
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
key == ToolKey::Escape && (self.drag.is_some() || self.current(cx).is_some())
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some((_, t, b)) = self.current(cx) else { return vec![] };
|
||||
let q = b.quad.map(|p| t.xf * p);
|
||||
let mut out: Vec<Overlay> = (0..4).map(|i| Overlay::Line { a: q[i], b: q[(i + 1) % 4], color: BLUE, dashed: false }).collect();
|
||||
for part in PARTS {
|
||||
let p = t.xf * b.handle(part);
|
||||
out.push(match part {
|
||||
Part::Rotate => Overlay::CurvePoint { p, color: BLUE, filled: false, size: 7.0 },
|
||||
Part::Move => Overlay::Anchor { p, color: BLUE, filled: true, size: 7.0 },
|
||||
_ => Overlay::Anchor { p, color: BLUE, filled: false, size: 7.0 },
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
||||
let Some((_, t, b)) = self.current(cx) else { return Cursor::Arrow };
|
||||
match Self::part_at(cx, t.xf, &b, p) {
|
||||
Some(Part::Rotate) => Cursor::rotate_about(t.xf * b.pivot, p),
|
||||
Some(Part::VScale) => Cursor::ResizeV,
|
||||
Some(Part::HScale) => Cursor::ResizeH,
|
||||
Some(Part::Scale) => Cursor::ResizeNeSw,
|
||||
Some(Part::Move) => Cursor::Move,
|
||||
None => Cursor::Arrow,
|
||||
}
|
||||
}
|
||||
|
||||
/// `{id, start}`: the selected character's type object and first byte, or `{}`.
|
||||
fn options(&self) -> Value {
|
||||
match self.target {
|
||||
Some((id, start)) => json!({"id": id.0, "start": start}),
|
||||
None => json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
self.target = None;
|
||||
match self.drag.take() {
|
||||
Some(d) if d.began => vec![Action::Cancel],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_doc::{Document, Node, Selection};
|
||||
|
||||
use super::*;
|
||||
use crate::testutil::{cx, paint};
|
||||
|
||||
/// 40 pt point type "Type" at (100, 200), selected.
|
||||
fn doc_with_type() -> (Document, NodeId, Selection) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let t = TextObject::point(Point::new(100.0, 200.0), "Type", CharStyle { size: 40.0, ..Default::default() });
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
(d, id, s)
|
||||
}
|
||||
|
||||
/// The selected character's box in document space.
|
||||
fn quad(t: &TouchTypeTool, cx: &ToolContext) -> [Point; 4] {
|
||||
let (_, text, b) = t.current(cx).expect("a character is selected");
|
||||
b.quad.map(|p| text.xf * p)
|
||||
}
|
||||
|
||||
/// A press at `from`, a drag to `to` and the release: the actions of each.
|
||||
fn drag(t: &mut TouchTypeTool, cx: &ToolContext, from: Point, to: Point) -> Vec<Vec<Action>> {
|
||||
[(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)]
|
||||
.into_iter()
|
||||
.map(|(kind, p)| t.pointer(cx, &PointerEvent::new(kind, p.x, p.y)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn preview(acts: &[Action]) -> &Value {
|
||||
acts.iter()
|
||||
.find_map(|a| match a {
|
||||
Action::Preview(c, p) if c == "text.touchType" => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no text.touchType preview: {acts:?}"))
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_selects_one_character_and_shows_its_box() {
|
||||
let (d, id, s) = doc_with_type();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = TouchTypeTool::default();
|
||||
assert!(t.overlays(&cx).is_empty(), "no character yet");
|
||||
// Somewhere in the second character, the "y".
|
||||
let t_object = text_of(&cx, id).unwrap().clone();
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), &t_object);
|
||||
let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap();
|
||||
let at = t_object.xf * (y.origin + Vec2::new(y.advance / 2.0, -10.0));
|
||||
let acts = drag(&mut t, &cx, at, at);
|
||||
assert!(acts.iter().all(Vec::is_empty), "a click changes nothing: {acts:?}");
|
||||
assert_eq!(t.options(), json!({"id": id.0, "start": 1}));
|
||||
// Its box: four sides and five handles, the rotate handle in the middle of the top edge.
|
||||
let ov = t.overlays(&cx);
|
||||
assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::Line { .. })).count(), 4);
|
||||
assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::Anchor { .. })).count(), 4);
|
||||
assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::CurvePoint { .. })).count(), 1);
|
||||
let q = quad(&t, &cx);
|
||||
assert!(q[0].x <= at.x && at.x <= q[1].x && q[0].y < at.y && at.y < q[3].y, "{q:?} holds {at:?}");
|
||||
assert_eq!(t.cursor(&cx, q[0].midpoint(q[1]), Mods::default()), Cursor::rotate_about(t_object.xf * y.origin, q[0].midpoint(q[1])));
|
||||
assert_eq!(t.cursor(&cx, q[2], Mods::default()), Cursor::ResizeH);
|
||||
assert_eq!(t.cursor(&cx, q[0], Mods::default()), Cursor::ResizeV);
|
||||
assert_eq!(t.cursor(&cx, q[1], Mods::default()), Cursor::ResizeNeSw);
|
||||
assert_eq!(t.cursor(&cx, q[0].midpoint(q[2]), Mods::default()), Cursor::Move);
|
||||
// Escape lets go of it.
|
||||
assert!(t.claims_key(&cx, ToolKey::Escape));
|
||||
t.key(&cx, ToolKey::Escape, Mods::default());
|
||||
assert!(t.overlays(&cx).is_empty() && t.options() == json!({}));
|
||||
// A click on type that isn't selected selects it too.
|
||||
let none = Selection::default();
|
||||
let cx = crate::testutil::cx(&d, &none, &p);
|
||||
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y));
|
||||
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_box_moves_the_character_along_its_line_and_up() {
|
||||
let (d, id, s) = doc_with_type();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = TouchTypeTool::default();
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), text_of(&cx, id).unwrap());
|
||||
let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap();
|
||||
let at = Point::new(100.0, 200.0) + (y.origin.to_vec2() + Vec2::new(y.advance / 2.0, -10.0));
|
||||
// 10 pt right and 4 pt up: one preview, one undo step.
|
||||
let acts = drag(&mut t, &cx, at, at + Vec2::new(10.0, -4.0));
|
||||
assert_eq!(acts[1][0], Action::Begin("Touch Type".into()));
|
||||
let p1 = preview(&acts[1]);
|
||||
assert_eq!((p1["id"].as_u64(), p1["start"].as_u64()), (Some(id.0), Some(1)));
|
||||
assert!(close(p1["dx"].as_f64().unwrap(), 10.0) && close(p1["dy"].as_f64().unwrap(), 4.0), "{p1}");
|
||||
assert_eq!(acts[2], vec![Action::Commit]);
|
||||
// The lower-left handle moves it too.
|
||||
let bl = quad(&t, &cx)[3];
|
||||
let acts = drag(&mut t, &cx, bl, bl + Vec2::new(-3.0, 2.0));
|
||||
let p2 = preview(&acts[1]);
|
||||
assert!(close(p2["dx"].as_f64().unwrap(), -3.0) && close(p2["dy"].as_f64().unwrap(), -2.0), "{p2}");
|
||||
// The first character has no character before it on its line: it moves only up and down.
|
||||
let first = Point::new(100.0, 200.0) + Vec2::new(lay.glyphs[0].advance / 2.0, -10.0);
|
||||
let acts = drag(&mut t, &cx, first, first + Vec2::new(10.0, -4.0));
|
||||
let p3 = preview(&acts[1]);
|
||||
assert_eq!(p3["start"], json!(0));
|
||||
assert!(close(p3["dx"].as_f64().unwrap(), 0.0) && close(p3["dy"].as_f64().unwrap(), 4.0), "{p3}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handles_scale_and_rotate_the_character() {
|
||||
let (d, id, s) = doc_with_type();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = TouchTypeTool::default();
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), text_of(&cx, id).unwrap());
|
||||
let y = lay.glyphs.iter().find(|g| g.byte == 1).unwrap();
|
||||
let at = Point::new(100.0, 200.0) + (y.origin.to_vec2() + Vec2::new(y.advance / 2.0, -10.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y));
|
||||
let [tl, tr, br, _] = quad(&t, &cx);
|
||||
let (w, h) = (tr.x - tl.x, br.y - tr.y);
|
||||
let above = 200.0 - tl.y;
|
||||
// The lower-right handle: horizontal scale; half the width again is 150 %.
|
||||
let acts = drag(&mut t, &cx, br, br + Vec2::new(w / 2.0, 7.0));
|
||||
let ps = preview(&acts[1]);
|
||||
assert!(close(ps["hScale"].as_f64().unwrap(), 150.0) && ps.get("vScale").is_none(), "{ps}");
|
||||
// The upper-left handle: vertical scale, measured from the baseline.
|
||||
let acts = drag(&mut t, &cx, tl, tl + Vec2::new(5.0, -above));
|
||||
let ps = preview(&acts[1]);
|
||||
assert!(close(ps["vScale"].as_f64().unwrap(), 200.0) && ps.get("hScale").is_none(), "{ps}");
|
||||
// The upper-right handle: both alike.
|
||||
let pen = Point::new(tl.x, 200.0);
|
||||
let acts = drag(&mut t, &cx, tr, pen + (tr - pen) * 0.5);
|
||||
let ps = preview(&acts[1]);
|
||||
assert!(close(ps["hScale"].as_f64().unwrap(), 50.0) && close(ps["vScale"].as_f64().unwrap(), 50.0), "{ps}");
|
||||
// The top handle: a quarter turn counter-clockwise about its pen position on the baseline.
|
||||
let top = tl.midpoint(tr);
|
||||
let pivot = Point::new(100.0, 200.0) + y.origin.to_vec2();
|
||||
let r = top - pivot;
|
||||
let acts = drag(&mut t, &cx, top, pivot + Vec2::new(r.y, -r.x));
|
||||
let ps = preview(&acts[1]);
|
||||
assert!(close(ps["rotation"].as_f64().unwrap(), 90.0), "{ps}");
|
||||
assert!(h > 0.0);
|
||||
// Escape mid-drag cancels the drag and keeps the character.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, top.x, top.y));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, top.x + 20.0, top.y));
|
||||
assert!(t.claims_key(&cx, ToolKey::Escape));
|
||||
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, top.x + 20.0, top.y)), vec![]);
|
||||
assert_eq!(t.options(), json!({"id": id.0, "start": 1}));
|
||||
// Switching tools mid-drag drops the drag.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, top.x, top.y));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, top.x + 20.0, top.y));
|
||||
assert_eq!(t.deactivate(&cx), vec![Action::Cancel]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_box_of_a_character_drawn_with_two_glyphs_holds_both() {
|
||||
// An All Caps ß is two glyphs (SS) with one byte range.
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let text = TextObject::point(Point::new(100.0, 200.0), "aße", CharStyle { size: 40.0, all_caps: true, ..Default::default() });
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), &text);
|
||||
let esses: Vec<_> = lay.glyphs.iter().filter(|g| g.byte == 1).collect();
|
||||
assert_eq!(esses.len(), 2);
|
||||
// A click on the second S selects the ß, and its box reaches from the first S to the end of the second.
|
||||
let second = esses[1];
|
||||
let at = text.xf * (second.origin + Vec2::new(second.advance / 2.0, -10.0));
|
||||
let mut t = TouchTypeTool::default();
|
||||
drag(&mut t, &cx, at, at);
|
||||
assert_eq!(t.options(), json!({"id": id.0, "start": 1}));
|
||||
let q = quad(&t, &cx);
|
||||
let ends = |g: &PositionedGlyph| (text.xf * glyph_quad(g).unwrap()[1]).x;
|
||||
assert!((q[1].x - ends(second)).abs() < 1e-9 && q[0].x < ends(esses[0]), "{q:?}");
|
||||
// Dragging the lower-right handle by half the box's width is 150 %.
|
||||
let w = q[1].x - q[0].x;
|
||||
let acts = drag(&mut t, &cx, q[2], q[2] + Vec2::new(w / 2.0, 0.0));
|
||||
assert!(close(preview(&acts[1])["hScale"].as_f64().unwrap(), 150.0));
|
||||
// Rotated, each S turns about its own origin: the box still holds both, square to the first.
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let text = TextObject::point(Point::new(100.0, 200.0), "aße", CharStyle { size: 40.0, all_caps: true, rotation: 30.0, ..Default::default() });
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let cx = crate::testutil::cx(&d, &s, &p);
|
||||
let t = TouchTypeTool { target: Some((id, 1)), ..Default::default() };
|
||||
let q = quad(&t, &cx);
|
||||
let (across, down) = (q[1] - q[0], q[3] - q[0]);
|
||||
assert!(across.dot(down).abs() < 1e-9, "a rectangle: {q:?}");
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), &text);
|
||||
for g in lay.glyphs.iter().filter(|g| g.byte == 1) {
|
||||
for c in glyph_quad(g).unwrap() {
|
||||
let v = text.xf * c - q[0];
|
||||
let (a, b) = (v.dot(across) / across.hypot2(), v.dot(down) / down.hypot2());
|
||||
assert!((-1e-9..=1.0 + 1e-9).contains(&a) && (-1e-9..=1.0 + 1e-9).contains(&b), "{c:?} outside {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A glyph skewed by its transform (the Skew effect of type on a path) keeps its own box.
|
||||
#[test]
|
||||
fn a_skewed_glyph_keeps_its_own_box() {
|
||||
let text = TextObject::point(Point::new(100.0, 200.0), "Type", CharStyle { size: 40.0, ..Default::default() });
|
||||
let mut lay = vectorcraft_text::layout(FontDb::global(), &text);
|
||||
lay.glyphs[1].xf *= Affine::skew(0.5, 0.0);
|
||||
let b = char_box(&text, &lay, 1).unwrap();
|
||||
let own = glyph_quad(&lay.glyphs[1]).unwrap();
|
||||
assert!(b.quad.iter().zip(own).all(|(a, b)| (*a - b).hypot() < 1e-9), "{:?} {own:?}", b.quad);
|
||||
}
|
||||
|
||||
/// `create` makes the Touch Type tool: a click with it selects a character.
|
||||
#[test]
|
||||
fn the_tool_is_made_by_its_id() {
|
||||
let (d, id, s) = doc_with_type();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), text_of(&cx, id).unwrap());
|
||||
let at = Point::new(100.0, 200.0) + Vec2::new(lay.glyphs[0].advance / 2.0, -10.0);
|
||||
let mut t = crate::create(ID);
|
||||
assert_eq!(t.id(), ID);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y));
|
||||
assert_eq!(t.options(), json!({"id": id.0, "start": 0}));
|
||||
}
|
||||
}
|
||||
@@ -3093,6 +3093,7 @@ effect Points Punkte
|
||||
Top in Front Oben im Vordergrund
|
||||
Top: Oben:
|
||||
Total Width: Gesamtbreite:
|
||||
Touch Type Touch-Type
|
||||
Touch Type Tool Touch-Type-Werkzeug
|
||||
Trace Nachzeichnen
|
||||
Trace the image with the Default preset Das Bild mit der Vorgabe „Standard“ nachzeichnen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3160,6 +3160,7 @@ mark-style Japanese Giapponese
|
||||
Top in Front Superiore davanti
|
||||
Top: In alto:
|
||||
Total Width: Larghezza totale:
|
||||
Touch Type Ritocco testo
|
||||
Touch Type Tool Strumento Ritocco testo
|
||||
Trace Ricalca
|
||||
Trace the image with the Default preset Ricalca l'immagine con il preset Predefinito
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2864,6 +2864,7 @@ mark-style Japanese 日本式
|
||||
Toolbar: Reset ツールバー: リセット
|
||||
Top: 上:
|
||||
Total Width: 合計幅:
|
||||
Touch Type 文字タッチ
|
||||
Touch Type Tool 文字タッチツール
|
||||
Trace トレース
|
||||
Trace the image with the Default preset 「デフォルト」プリセットで画像をトレース
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2858,6 +2858,7 @@ mark-style Japanese 일본식
|
||||
Toolbar: Reset 도구 막대: 재설정
|
||||
Top: 위쪽:
|
||||
Total Width: 전체 너비:
|
||||
Touch Type 터치 문자
|
||||
Touch Type Tool 터치 문자 도구
|
||||
Trace 추적
|
||||
Trace the image with the Default preset 기본 프리셋으로 이미지 추적
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1143,6 +1143,7 @@ lighting Light Światło
|
||||
Symbol Stainer Tool Narzędzie Barwienie symboli
|
||||
Symbol Styler Tool Narzędzie Stylizowanie symboli
|
||||
Touch Type Tool Narzędzie Tekst dotykowy
|
||||
Touch Type Tekst dotykowy
|
||||
Twirl Tool Narzędzie Wir
|
||||
Type on a Path Tool Narzędzie Tekst na ścieżce
|
||||
Type Tool Narzędzie Tekst
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3156,6 +3156,7 @@ mark-style Japanese Японский
|
||||
Top in Front Верхний спереди
|
||||
Top: Вверху:
|
||||
Total Width: Общая ширина:
|
||||
Touch Type Настройка текста
|
||||
Touch Type Tool Инструмент «Настройка текста»
|
||||
Trace Трассировать
|
||||
Trace the image with the Default preset Трассировать изображение с набором «По умолчанию»
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3153,6 +3153,7 @@ effect Points Точки
|
||||
Top in Front Верхній спереду
|
||||
Top: Вгорі:
|
||||
Total Width: Загальна ширина:
|
||||
Touch Type Налаштування тексту
|
||||
Touch Type Tool Інструмент «Налаштування тексту»
|
||||
Trace Трасувати
|
||||
Trace the image with the Default preset Трасувати зображення з набором "За замовчуванням"
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 721 and column 40.
|
@@ -3486,6 +3486,7 @@ mark-style Japanese Kiểu Nhật
|
||||
Top: Trên:
|
||||
Total Width: Tổng chiều rộng:
|
||||
Total: Tổng:
|
||||
Touch Type Chạm để nhập chữ
|
||||
Touch Type Tool Công cụ Chạm để nhập chữ
|
||||
Trace Vector hóa
|
||||
Trace the image with the Default preset Vector hóa ảnh với thiết lập sẵn mặc định
|
||||
|
||||
|
@@ -3134,6 +3134,7 @@ mark-style Japanese 日式
|
||||
Top-to-Top Leading 上緣至上緣行距
|
||||
Top: 上:
|
||||
Total Width: 總寬度:
|
||||
Touch Type 觸控文字
|
||||
Touch Type Tool 觸控文字工具
|
||||
Trace 描圖
|
||||
Trace the image with the Default preset 使用「預設」預設集描圖影像
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
+26
@@ -3396,6 +3396,32 @@ A digit the font lacks comes from a fallback font. Default and saved only when s
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"digits":"hindi"}}}
|
||||
```
|
||||
|
||||
## Touch Type
|
||||
|
||||
The Touch Type tool (`touchType`, Shift+T) changes one character at a time. A click on a character of a type object
|
||||
(`pointer_gesture` with `touchType`) selects the object and that character and shows a box around the character.
|
||||
Dragging the box or its lower-left handle moves the character along its line and up or down. The upper-left handle
|
||||
scales it vertically, the lower-right handle scales it horizontally, the upper-right handle scales it proportionally,
|
||||
and the handle in the middle of the top edge rotates it about its pen position on the baseline. Escape lets go of the character. Each
|
||||
drag is one `text.touchType {id, start, dx?, dy?, hScale?, vScale?, rotation?}` and one undo step. `start` is the byte
|
||||
offset of the character; a ligature (one glyph for several characters) changes as a whole, and the reply's `start` and
|
||||
`end` give its bytes. `dx` (document points, in reading order) changes the kerning of the character before it on
|
||||
its line and its own kerning, so the character moves by `dx` and the characters after it stay where they are. The first
|
||||
character of a line and a right-to-left character take no `dx`, and `dx` is limited to what kerning from -1000 to 10000 (1/1000 em) on the two
|
||||
characters can give. `dy` raises it by baseline shift, which moves a rotated character along its own up axis. The tool
|
||||
splits a drag of a character rotated by up to about 84 degrees between kerning and baseline shift, so the character
|
||||
follows the pointer; nearer a quarter turn, a drag moves it only along its line. `hScale`, `vScale`
|
||||
and `rotation` set its scales and rotation as `text.setRangeStyle` does. Where the font kerned the character with its
|
||||
neighbors, the kerning of the two characters keeps it and the characters after it where they were along the line; a
|
||||
wider or narrower character moves the characters after it by the change in its width. Touch Type gives the character
|
||||
before it a kerning value of its own (with `dx`, and with any other change where the font kerned the character with
|
||||
its neighbors), and that character can then move by the font's kerning pair with the one before
|
||||
it, as with any style change to one character (`text.setRangeStyle`). The type stays editable.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.touchType","params":{"id":7,"start":1,"dx":10,"dy":4}}}
|
||||
```
|
||||
|
||||
## New type in a Japanese interface
|
||||
|
||||
While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em
|
||||
|
||||
@@ -509,6 +509,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked.
|
||||
Duplicate Artboards, the Artboard tool's Alt-drag and Paste give the new artboard an id that no other artboard has, also when an artboard's id is the largest possible value (#1718). Deleting the copy or the original then leaves the other's guides.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Touch Type:** a click with the Touch Type tool (Shift+T) selects one character and shows a box around it. Dragging the box or its lower-left handle moves the character. The upper-left handle scales it vertically, the lower-right handle scales it horizontally, the upper-right handle scales it proportionally, and the upper-middle handle rotates it. Each drag is one undo step that sets the character's kerning, baseline shift, scales or rotation, and the type stays editable (`text.touchType`; behavior reference: [Touch Type tool](https://web.archive.org/web/20261007185617/https://helpx.adobe.com/illustrator/using/tool-techniques/touch-type-tool.html), [Add text with the Type, Area Type, and Touch Type tools](https://web.archive.org/web/20260926095922/https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/add-text-illustrator-text-tools.html)).
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
A Space+Shift drag rotates the view whatever the tool, as a Rotate View tool drag does, and Shift does not round the angle to 15°.
|
||||
- **Swatches panel clicks:** Ctrl/Cmd-clicking New Swatch makes a spot color, and Ctrl/Cmd+Shift-clicking it makes a global process color.
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary |
|
||||
| Smart Punctuation, Change Case, Find & Replace | done | |
|
||||
| Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | |
|
||||
| Touch Type tool | missing | listed in the toolbar, not implemented |
|
||||
| Touch Type tool | done | moves, scales and rotates one character through its kerning, baseline shift, scales and rotation (`text.touchType`); the first character of a line and right-to-left characters move only up and down |
|
||||
| Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model |
|
||||
| Snap to Glyph | missing | menu stub |
|
||||
| Thai, Lao, Khmer, Myanmar: shaping (marks, reordering), lines broken between words, word-by-word caret and double-click | done | words found by ICU4X's line segmenter: dictionaries on the desktop, LSTM models on the web |
|
||||
|
||||
Reference in New Issue
Block a user