mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
1047 lines
46 KiB
Rust
1047 lines
46 KiB
Rust
//! 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;
|