mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -243,9 +243,15 @@ pub fn hatch(path: &BezPath, gap: f64) -> BezPath {
|
||||
struct RegionCache {
|
||||
key: Option<(usize, Vec<NodeId>)>,
|
||||
regions: Vec<(po::Region, BezPath, Rect)>,
|
||||
/// Live Paint faces (every path an edge, open ones included) rather than Shape Builder
|
||||
/// regions (the filled areas of the shapes).
|
||||
live: bool,
|
||||
}
|
||||
|
||||
impl RegionCache {
|
||||
fn live_paint() -> Self {
|
||||
Self { live: true, ..Default::default() }
|
||||
}
|
||||
fn get(&mut self, cx: &ToolContext) -> &[(po::Region, BezPath, Rect)] {
|
||||
let key = (cx.doc as *const Document as usize, cx.selection.objects.clone());
|
||||
if self.key.as_ref() != Some(&key) {
|
||||
@@ -254,7 +260,8 @@ impl RegionCache {
|
||||
self.regions = if shapes.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
po::regions(&shapes)
|
||||
let regions = if self.live { po::live_paint(&shapes).0 } else { po::regions(&shapes) };
|
||||
regions
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let bp = r.path.to_bezpath();
|
||||
@@ -437,12 +444,17 @@ fn lp_overlays(h: &LpHover, cache: &RegionCache) -> Vec<Overlay> {
|
||||
/// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click
|
||||
/// paints the nearest edge with the current stroke. Clicking selected ordinary paths makes them a
|
||||
/// Live Paint group first.
|
||||
#[derive(Default)]
|
||||
pub struct LivePaintBucketTool {
|
||||
cache: RegionCache,
|
||||
hover: LpHover,
|
||||
}
|
||||
|
||||
impl Default for LivePaintBucketTool {
|
||||
fn default() -> Self {
|
||||
Self { cache: RegionCache::live_paint(), hover: LpHover::None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for LivePaintBucketTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"livePaintBucket"
|
||||
@@ -500,12 +512,17 @@ impl Tool for LivePaintBucketTool {
|
||||
// ---------- Live Paint Selection ----------
|
||||
|
||||
/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge.
|
||||
#[derive(Default)]
|
||||
pub struct LivePaintSelectionTool {
|
||||
cache: RegionCache,
|
||||
hover: LpHover,
|
||||
}
|
||||
|
||||
impl Default for LivePaintSelectionTool {
|
||||
fn default() -> Self {
|
||||
Self { cache: RegionCache::live_paint(), hover: LpHover::None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for LivePaintSelectionTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"livePaintSelection"
|
||||
|
||||
@@ -385,6 +385,21 @@ pub trait Tool: Send {
|
||||
fn text_input(&mut self, _cx: &ToolContext, _s: &str) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// IME composition (marked text) for the tool that wants text: `text` replaces the previous
|
||||
/// marked text (or the selection); an empty `text` ends the composition. `active_chars` is the
|
||||
/// clause being converted, in characters of `text`. The committed result arrives through
|
||||
/// [`Tool::text_input`].
|
||||
fn ime_preedit(&mut self, _cx: &ToolContext, _text: &str, _active_chars: Option<std::ops::Range<usize>>) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// Is uncommitted IME text being shown? Keys, shortcuts and Undo wait while it is.
|
||||
fn composing(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Where the IME candidate window goes: the caret line (top, bottom) in document space.
|
||||
fn ime_caret(&self, _cx: &ToolContext) -> Option<(Point, Point)> {
|
||||
None
|
||||
}
|
||||
fn notify(&mut self, _cx: &ToolContext, _what: &str) {}
|
||||
/// Called when the user switches away (finish pending work).
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
|
||||
@@ -21,6 +21,9 @@ enum State {
|
||||
Moving {
|
||||
start: Point,
|
||||
began: bool,
|
||||
/// Shift-pressed on this selected object: released without a drag, it leaves the
|
||||
/// selection.
|
||||
deselect: Option<NodeId>,
|
||||
},
|
||||
Scaling {
|
||||
handle: Handle,
|
||||
@@ -141,16 +144,19 @@ impl Tool for SelectionTool {
|
||||
Some(h) => {
|
||||
let top = h.top_object(cx.isolation);
|
||||
let mut out = vec![];
|
||||
let mut deselect = None;
|
||||
if m.shift {
|
||||
out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 })));
|
||||
// A Shift-click takes a selected object out of the selection when it
|
||||
// is released; a Shift-drag moves the selection, constrained.
|
||||
if cx.selection.contains(top) {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
deselect = Some(top);
|
||||
} else {
|
||||
out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 })));
|
||||
}
|
||||
} else if !cx.selection.contains(top) {
|
||||
out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] })));
|
||||
}
|
||||
self.state = State::Moving { start: p, began: false };
|
||||
self.state = State::Moving { start: p, began: false, deselect };
|
||||
out
|
||||
}
|
||||
None => {
|
||||
@@ -185,7 +191,7 @@ impl Tool for SelectionTool {
|
||||
d = vectorcraft_geom::Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0);
|
||||
self.guides.clear();
|
||||
}
|
||||
self.state = State::Moving { start, began: true };
|
||||
self.state = State::Moving { start, began: true, deselect: None };
|
||||
self.measure = Some((p, cx.offset_label(d.x, d.y)));
|
||||
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
|
||||
out
|
||||
@@ -215,12 +221,16 @@ impl Tool for SelectionTool {
|
||||
self.state = State::Idle;
|
||||
c.finish()
|
||||
}
|
||||
(PointerKind::Up, State::Moving { began, .. }) => {
|
||||
(PointerKind::Up, State::Moving { began, deselect, .. }) => {
|
||||
self.state = State::Idle;
|
||||
self.measure = None;
|
||||
self.guides.clear();
|
||||
self.targets = None;
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
match (began, deselect) {
|
||||
(true, _) => vec![Action::Commit],
|
||||
(false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
|
||||
(false, None) => vec![],
|
||||
}
|
||||
}
|
||||
(PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => {
|
||||
self.state = State::Idle;
|
||||
@@ -339,6 +349,48 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_on_a_selected_object_moves_it_constrained() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() });
|
||||
// Pressing keeps the selection: nothing is toggled yet.
|
||||
assert!(t.pointer(&cx, &shift(PointerKind::Down, 150.0, 150.0)).is_empty());
|
||||
let a = t.pointer(&cx, &shift(PointerKind::Drag, 190.0, 160.0));
|
||||
assert_eq!(a[0], Action::Begin("Move".into()));
|
||||
assert!(
|
||||
matches!(&a[1], Action::Preview(c, v) if c == "object.transform"
|
||||
&& v["matrix"][4].as_f64().is_some_and(|x| x > 39.0) && v["matrix"][5] == 0.0),
|
||||
"moved right, constrained to horizontal: {a:?}"
|
||||
);
|
||||
assert_eq!(t.pointer(&cx, &shift(PointerKind::Up, 190.0, 160.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_toggles_an_object_on_release() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let p = paint();
|
||||
let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() });
|
||||
let toggle = vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))];
|
||||
// Selected: it leaves the selection when the click is released.
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let cx1 = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
assert!(t.pointer(&cx1, &shift(PointerKind::Down, 150.0, 150.0)).is_empty());
|
||||
assert_eq!(t.pointer(&cx1, &shift(PointerKind::Up, 150.0, 150.0)), toggle);
|
||||
// Not selected: it joins the selection at once, ready to be dragged.
|
||||
let s = Selection::default();
|
||||
let cx2 = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
assert_eq!(t.pointer(&cx2, &shift(PointerKind::Down, 150.0, 150.0)), toggle);
|
||||
assert!(t.pointer(&cx2, &shift(PointerKind::Up, 150.0, 150.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
+111
-6
@@ -12,7 +12,11 @@
|
||||
//! `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`]).
|
||||
//!
|
||||
//! 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};
|
||||
@@ -38,6 +42,15 @@ struct Typing {
|
||||
cur: Vec<TextRun>,
|
||||
}
|
||||
|
||||
/// 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>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TypeTool {
|
||||
vertical: bool,
|
||||
@@ -52,6 +65,7 @@ pub struct TypeTool {
|
||||
/// Horizontal position kept by Up/Down (text space).
|
||||
goal_x: Option<f64>,
|
||||
typing: Option<Typing>,
|
||||
preedit: Option<Preedit>,
|
||||
press: Option<Point>,
|
||||
drag: Option<Point>,
|
||||
/// The press landed in the edited text: dragging selects.
|
||||
@@ -113,8 +127,10 @@ impl TypeTool {
|
||||
(self.caret.min(self.anchor), self.caret.max(self.anchor))
|
||||
}
|
||||
|
||||
/// End the typing session (one undo step).
|
||||
/// 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![] }
|
||||
}
|
||||
|
||||
@@ -187,6 +203,7 @@ impl TypeTool {
|
||||
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).
|
||||
@@ -277,6 +294,14 @@ impl TypeTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 and suffix of `a` and `b` (on char boundaries, not overlapping).
|
||||
fn common_affixes(a: &str, b: &str) -> (usize, usize) {
|
||||
let mut p = 0;
|
||||
@@ -371,6 +396,15 @@ impl Tool for TypeTool {
|
||||
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![];
|
||||
}
|
||||
@@ -387,13 +421,17 @@ impl Tool for TypeTool {
|
||||
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 s.len() > 1 && s == clip && !cx.paste_plain_text {
|
||||
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![];
|
||||
@@ -485,6 +523,51 @@ impl Tool for TypeTool {
|
||||
_ => 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.finish(cx)
|
||||
}
|
||||
@@ -510,13 +593,14 @@ impl Tool for TypeTool {
|
||||
self.fresh = Self::text(cx, id).filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id);
|
||||
}
|
||||
}
|
||||
/// `{editing, start, end, caret, anchor, typing}` — the Character panel styles `start..end`.
|
||||
/// `{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()})
|
||||
json!({"editing": id.0, "start": a, "end": b, "caret": self.caret, "anchor": self.anchor, "typing": self.typing.is_some(), "composing": self.preedit.is_some()})
|
||||
}
|
||||
None => json!({"editing": null}),
|
||||
}
|
||||
@@ -525,7 +609,10 @@ impl Tool for TypeTool {
|
||||
/// `commitTyping` (the caller commits the engine interaction).
|
||||
fn set_option(&mut self, key: &str, v: &Value) {
|
||||
match key {
|
||||
"commitTyping" | "endTyping" => self.typing = None,
|
||||
"commitTyping" | "endTyping" => {
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
}
|
||||
"selectAll" if self.editing.is_some() => {
|
||||
self.anchor = 0;
|
||||
self.caret = usize::MAX / 4;
|
||||
@@ -544,6 +631,7 @@ impl Tool for TypeTool {
|
||||
}
|
||||
"stopEditing" => {
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
self.editing = None;
|
||||
}
|
||||
_ => {}
|
||||
@@ -578,7 +666,24 @@ impl Tool for TypeTool {
|
||||
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 a == b {
|
||||
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 {
|
||||
|
||||
@@ -228,6 +228,52 @@ fn vertical_tools_preserve_their_ids_and_create_vertical_text() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_ranges_convert_characters_to_bytes() {
|
||||
assert_eq!(char_range_to_bytes("ががく", 1..3), Some(3..9));
|
||||
assert_eq!(char_range_to_bytes("ががく", 3..3), Some(9..9));
|
||||
assert_eq!(char_range_to_bytes("a雅b", 1..2), Some(1..4));
|
||||
assert_eq!(char_range_to_bytes("ががく", 2..4), None, "past the end");
|
||||
#[allow(clippy::reversed_empty_ranges)]
|
||||
let backwards = 2..1;
|
||||
assert_eq!(char_range_to_bytes("ががく", backwards), None, "backwards");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_text_is_underlined_with_the_converting_clause_thick() {
|
||||
let (mut d, id, mut tool) = editing("曲");
|
||||
let snap = d.clone();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
tool.caret = 3;
|
||||
tool.anchor = 3;
|
||||
let acts = tool.ime_preedit(&cx(&d, &sel, &p), "雅楽演奏", Some(2..4));
|
||||
apply(&mut d, &snap, &acts);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "曲雅楽演奏");
|
||||
assert_eq!(tool.preedit, Some(Preedit { range: 3..15, active: Some(6..12) }));
|
||||
let widths: Vec<f32> = tool
|
||||
.overlays(&cx(&d, &sel, &p))
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
Overlay::Path { width, .. } => Some(*width),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(widths, vec![1.0, 2.5], "a thin underline, then the thick one under 演奏");
|
||||
// The candidate window follows the converting clause.
|
||||
let (top, _) = tool.ime_caret(&cx(&d, &sel, &p)).unwrap();
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), t);
|
||||
let (at, _) = vectorcraft_text::caret_position(&lay, 9);
|
||||
assert!((top - t.xf * at).hypot() < 1e-6);
|
||||
// Keys wait for the IME; a commit replaces the marked text.
|
||||
assert!(key(&mut tool, &d, ToolKey::Backspace, Mods::default()).is_empty());
|
||||
let acts = tool.text_input(&cx(&d, &sel, &p), "雅楽演奏会");
|
||||
apply(&mut d, &snap, &acts);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "曲雅楽演奏会");
|
||||
assert!(!tool.composing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_follow_the_columns_of_vertical_type() {
|
||||
let (mut d, id) = doc_with_text("§§§\n§§§");
|
||||
|
||||
Reference in New Issue
Block a user