mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
@@ -55,6 +55,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert),
|
||||
// Neither journalled nor undoable: what is composed is a preview until it's typed.
|
||||
CommandSpec {
|
||||
id: "text.compose",
|
||||
label: "Compose Text",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{text} — input-method text being composed (a dead key's accent), shown at the caret in the text's format until text.insert types the result; \"\" drops it",
|
||||
enabled: has_doc,
|
||||
run: compose_text,
|
||||
journal: false,
|
||||
undoable: false,
|
||||
},
|
||||
cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete),
|
||||
cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret),
|
||||
cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| {
|
||||
@@ -508,6 +520,30 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `text.compose`: show input-method text at the caret, typed in the format the next
|
||||
/// character would get, without making it part of the story. Replaces what was composed before.
|
||||
fn compose_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = super::text_param(p, "text");
|
||||
s.end_composition();
|
||||
if text.is_empty() {
|
||||
return ok();
|
||||
}
|
||||
has_text(s).map_err(|e| bad("text.compose", &e))?;
|
||||
// The preview types the text; the format chosen at the caret is still for the real typing.
|
||||
let typing = s.typing_format.clone();
|
||||
s.begin_interaction("Type")?;
|
||||
let r = s.preview("text.insert", json!({"text": text, "raw": true}));
|
||||
s.typing_format = typing;
|
||||
if let Err(e) = r {
|
||||
s.cancel_interaction();
|
||||
return Err(e);
|
||||
}
|
||||
let uid = s.doc()?.uid;
|
||||
s.composition = Some((uid, text.len()));
|
||||
let pos = s.doc()?.selection.text.map_or(0, |t| t.focus);
|
||||
Ok(json!({"range": [pos.saturating_sub(text.len()), pos]}))
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = super::text_param(p, "text");
|
||||
// Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`).
|
||||
@@ -2908,3 +2944,67 @@ mod gradient_text_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod compose_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A story "Ta" with the caret after "T" and 30 pt chosen for the next typing.
|
||||
fn session() -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Ta"})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
s.set_tool("type");
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 1})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
|
||||
(s, sid)
|
||||
}
|
||||
|
||||
fn text(s: &Session, sid: u64) -> String {
|
||||
s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone()
|
||||
}
|
||||
|
||||
/// ⌥E shows the accent in the story, in the format it will be typed in, then I types "í":
|
||||
/// one undo step, and the accent was never part of the document.
|
||||
#[test]
|
||||
fn composed_text_shows_in_the_typing_format_until_it_is_typed() {
|
||||
let (mut s, sid) = session();
|
||||
let r = s.execute("text.compose", &json!({"text": "´"})).unwrap();
|
||||
assert_eq!(r["range"], json!([1, 3]));
|
||||
assert_eq!(text(&s, sid), "T´a");
|
||||
let size_at = |s: &Session, b| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size;
|
||||
assert_eq!(size_at(&s, 2), Some(30.0), "the accent is shown in the format chosen at the caret");
|
||||
assert_eq!(s.composition().map(|c| c.2), Some(1..3));
|
||||
s.execute("text.compose", &json!({"text": "¨"})).unwrap();
|
||||
assert_eq!(text(&s, sid), "T¨a", "a new composition replaces the last one");
|
||||
s.execute("text.compose", &json!({"text": ""})).unwrap();
|
||||
assert_eq!(text(&s, sid), "Ta");
|
||||
assert!(s.composition().is_none());
|
||||
s.execute("text.insert", &json!({"text": "í"})).unwrap();
|
||||
assert_eq!(text(&s, sid), "Tía");
|
||||
assert_eq!(size_at(&s, 2), Some(30.0), "the typing format survived the composition");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, sid), "Ta");
|
||||
}
|
||||
|
||||
/// Anything else that edits, a tool change or a drag drops the uncommitted accent.
|
||||
#[test]
|
||||
fn other_edits_drop_the_composed_text() {
|
||||
for act in 0..3 {
|
||||
let (mut s, sid) = session();
|
||||
s.execute("text.compose", &json!({"text": "´"})).unwrap();
|
||||
match act {
|
||||
0 => {
|
||||
s.execute("text.move", &json!({"dir": "right"})).unwrap();
|
||||
}
|
||||
1 => s.set_tool("selection"),
|
||||
_ => s.begin_interaction("Move").unwrap(),
|
||||
}
|
||||
assert_eq!(text(&s, sid), "Ta", "case {act}");
|
||||
assert!(s.composition().is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +291,9 @@ pub struct Session {
|
||||
pub(crate) text_drag: bool,
|
||||
/// Character formatting chosen at a bare caret, for the text typed there next.
|
||||
pub(crate) typing_format: Option<cmd::text::TypingFormat>,
|
||||
/// Text an input method is composing (a dead key's accent), shown in the story as an
|
||||
/// interaction preview until it commits: (document uid, byte length before the caret).
|
||||
pub(crate) composition: Option<(u64, usize)>,
|
||||
/// The open Object Library (File › New / Open Library).
|
||||
pub library: Option<cmd::library::Library>,
|
||||
/// The open book (File › Open Book).
|
||||
@@ -324,6 +327,7 @@ impl Session {
|
||||
transforms: Default::default(),
|
||||
text_drag: false,
|
||||
typing_format: None,
|
||||
composition: None,
|
||||
library: None,
|
||||
conveyor: Vec::new(),
|
||||
book: None,
|
||||
@@ -433,6 +437,10 @@ impl Session {
|
||||
|
||||
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
// Text still being composed isn't part of the story: any other edit drops it first.
|
||||
if id != "text.compose" && (spec.undoable || spec.journal) {
|
||||
self.end_composition();
|
||||
}
|
||||
if let Err(e) = (spec.enabled)(self) {
|
||||
// A command that takes `ids` acts on the objects the call names, so it needs no
|
||||
// selection: with a text caret or nothing selected, `{"ids": [5]}` still runs.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{CellAddr, StoryId};
|
||||
use designcraft_geom::Unit;
|
||||
pub use designcraft_tools::SnapView;
|
||||
use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey};
|
||||
@@ -60,6 +61,7 @@ impl Session {
|
||||
if self.tool.id() == id {
|
||||
return;
|
||||
}
|
||||
self.end_composition();
|
||||
self.tool = designcraft_tools::create(id);
|
||||
// Any other tool unloads the place cursor.
|
||||
if id != "placeGun" {
|
||||
@@ -150,7 +152,29 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text an input method is composing at the caret, as story bytes: it is in the
|
||||
/// document as a preview until `text.compose` commits or drops it.
|
||||
pub fn composition(&self) -> Option<(StoryId, Option<CellAddr>, std::ops::Range<usize>)> {
|
||||
let (uid, len) = self.composition?;
|
||||
let st = self.active().filter(|st| st.uid == uid)?;
|
||||
let t = st.selection.text?;
|
||||
Some((t.story, t.cell, t.focus.checked_sub(len)?..t.focus))
|
||||
}
|
||||
|
||||
/// Drop the text being composed: the document goes back to how it was before it.
|
||||
pub fn end_composition(&mut self) {
|
||||
let Some((uid, _)) = self.composition.take() else { return };
|
||||
if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid)
|
||||
&& let Some(it) = st.interaction.take()
|
||||
{
|
||||
st.doc = it.doc;
|
||||
st.selection = it.selection;
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
|
||||
self.end_composition();
|
||||
let st = self.doc_mut()?;
|
||||
st.interaction = Some(Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None });
|
||||
Ok(())
|
||||
|
||||
+111
-18
@@ -1044,7 +1044,11 @@ fn draw_text_selection(
|
||||
cells: Option<designcraft_doc::TableSel>,
|
||||
) {
|
||||
let cs = app.session.cache.get(doc, ts.story, None);
|
||||
let range = ts.range();
|
||||
// Text an input method is composing at the caret is marked like it is in every Mac text
|
||||
// field: a yellow box behind the glyphs.
|
||||
let composing = app.session.composition().filter(|c| c.0 == ts.story && c.1 == ts.cell && ts.is_caret()).map(|c| c.2);
|
||||
let range = composing.clone().unwrap_or_else(|| ts.range());
|
||||
let tint = if composing.is_some() { COMPOSING_FILL.gamma_multiply(0.7) } else { Color32::from_rgba_unmultiplied(80, 140, 255, 110) };
|
||||
// Selected table cells: tint every selected cell.
|
||||
if let Some(sel) = cells.filter(|c| c.story == ts.story) {
|
||||
let selected = doc.story(sel.story).and_then(|s| s.tables.get(&sel.table)).map(|t| t.selected_owners(sel.range)).unwrap_or_default();
|
||||
@@ -1097,7 +1101,7 @@ fn draw_text_selection(
|
||||
let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64);
|
||||
let poly =
|
||||
[Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p)));
|
||||
painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
|
||||
painter.add(egui::Shape::convex_polygon(poly.to_vec(), tint, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1133,7 +1137,7 @@ fn draw_text_selection(
|
||||
}
|
||||
}
|
||||
if !runs.is_empty() {
|
||||
draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs);
|
||||
draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs, composing.is_some());
|
||||
}
|
||||
if ts.is_caret()
|
||||
&& let Some((fi, x, bl, asc, desc)) = compose::caret(&cs, ts.focus)
|
||||
@@ -1141,22 +1145,38 @@ fn draw_text_selection(
|
||||
&& let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame))
|
||||
{
|
||||
let m = a * doc.text_xf(it);
|
||||
// Type on a path: the caret stands across the path where it is along it.
|
||||
let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p));
|
||||
let p0 = xf.to_screen(m * on(Point::new(x, bl - asc)));
|
||||
let p1 = xf.to_screen(m * on(Point::new(x, bl + desc)));
|
||||
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
|
||||
if blink {
|
||||
// Type on a path: the caret stands across the path where it is along it.
|
||||
let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p));
|
||||
let p0 = xf.to_screen(m * on(Point::new(x, bl - asc)));
|
||||
let p1 = xf.to_screen(m * on(Point::new(x, bl + desc)));
|
||||
painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
remember_ime_caret(painter, p0, p1);
|
||||
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the text caret stood on screen last frame: the OS puts its input-method window there.
|
||||
fn ime_caret_id() -> egui::Id {
|
||||
egui::Id::new("canvas_ime_caret")
|
||||
}
|
||||
|
||||
/// The input method is composing text (sent to `text.compose`) that it hasn't committed.
|
||||
fn ime_composing_id() -> egui::Id {
|
||||
egui::Id::new("canvas_ime_composing")
|
||||
}
|
||||
|
||||
/// Remembers the caret at `p0`–`p1` for the input method's window.
|
||||
fn remember_ime_caret(painter: &egui::Painter, p0: egui::Pos2, p1: egui::Pos2) {
|
||||
painter.ctx().data_mut(|d| d.insert_temp(ime_caret_id(), egui::Rect::from_two_pos(p0, p1)));
|
||||
}
|
||||
|
||||
/// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_cell_selection(
|
||||
_app: &DesignApp,
|
||||
app: &DesignApp,
|
||||
painter: &egui::Painter,
|
||||
xf: &Xf,
|
||||
doc: &Document,
|
||||
@@ -1174,7 +1194,9 @@ fn draw_cell_selection(
|
||||
let Some(ft) = cs.frames.get(fi) else { return };
|
||||
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return };
|
||||
let m = a * doc.text_xf(it) * designcraft_geom::Affine::translate(origin.to_vec2());
|
||||
let range = ts.range();
|
||||
let composing = app.session.composition().filter(|c| c.0 == ts.story && c.1 == ts.cell && ts.is_caret()).map(|c| c.2);
|
||||
let range = composing.clone().unwrap_or_else(|| ts.range());
|
||||
let tint = if composing.is_some() { COMPOSING_FILL.gamma_multiply(0.6) } else { Color32::from_rgba_unmultiplied(80, 140, 255, 110) };
|
||||
if !range.is_empty()
|
||||
&& let Some(cft) = text.frames.first()
|
||||
{
|
||||
@@ -1187,7 +1209,7 @@ fn draw_cell_selection(
|
||||
let past = e == l.range.end && range.end > l.range.end;
|
||||
for q in compose::highlight_quads(l, s, e, past) {
|
||||
let poly = q.map(|p| xf.to_screen(m * p));
|
||||
painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
|
||||
painter.add(egui::Shape::convex_polygon(poly.to_vec(), tint, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1198,20 +1220,25 @@ fn draw_cell_selection(
|
||||
}
|
||||
{
|
||||
let m = a * doc.text_xf(it);
|
||||
let (p0, p1) = (xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc)));
|
||||
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
|
||||
if blink {
|
||||
painter.line_segment(
|
||||
[xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))],
|
||||
Stroke::new(1.0, Color32::BLACK),
|
||||
);
|
||||
painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
remember_ime_caret(painter, p0, p1);
|
||||
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
|
||||
}
|
||||
}
|
||||
|
||||
/// The box behind text an input method is composing.
|
||||
const COMPOSING_FILL: Color32 = Color32::from_rgb(255, 247, 153);
|
||||
|
||||
/// InDesign draws selected text inverted: a black highlight with the selected glyphs in the
|
||||
/// inverse of their colour (white for black text). The highlight and glyph outlines are
|
||||
/// rasterised with `vello_cpu` into a small screen-space texture over the selection.
|
||||
/// `composing`: the text is an input method's, still being composed: a yellow box with the
|
||||
/// glyphs in their own colour.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_inverse_highlight(
|
||||
app: &DesignApp,
|
||||
painter: &egui::Painter,
|
||||
@@ -1220,6 +1247,7 @@ fn draw_inverse_highlight(
|
||||
cs: &compose::ComposedStory,
|
||||
story: designcraft_doc::StoryId,
|
||||
runs: &[(usize, Affine, Vec<[Point; 4]>, Vec<(usize, usize)>)],
|
||||
composing: bool,
|
||||
) {
|
||||
use designcraft_render::vello_cpu::{self, kurbo, peniko};
|
||||
let ctx = painter.ctx();
|
||||
@@ -1246,7 +1274,7 @@ fn draw_inverse_highlight(
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let key = egui::Id::new(("inverse_sel", st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, story.0))
|
||||
.with((runs.iter().map(|r| r.2.len() + r.3.len() * 7919).sum::<usize>(), w, h))
|
||||
.with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus))));
|
||||
.with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus)), composing));
|
||||
let cache_id = egui::Id::new("inverse_sel_tex");
|
||||
let cached: Option<(egui::Id, egui::TextureHandle)> = ctx.data(|d| d.get_temp(cache_id));
|
||||
let tex = match cached {
|
||||
@@ -1254,7 +1282,8 @@ fn draw_inverse_highlight(
|
||||
_ => {
|
||||
let view = xf.affine(ppp as f64, bbox.min);
|
||||
let mut rc = vello_cpu::RenderContext::new_with(w as u16, h as u16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
rc.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255));
|
||||
let [br, bg, bb, _] = if composing { COMPOSING_FILL.to_array() } else { [0, 0, 0, 255] };
|
||||
rc.set_paint(peniko::Color::from_rgba8(br, bg, bb, 255));
|
||||
for (_, m, quads, _) in runs {
|
||||
rc.set_transform(view * *m);
|
||||
for q in quads {
|
||||
@@ -1275,8 +1304,12 @@ fn draw_inverse_highlight(
|
||||
let Some(g) = l.glyphs.get(gi) else { continue };
|
||||
let Some(style) = cs.styles.get(g.style as usize) else { continue };
|
||||
let Some(c) = doc.resolve_color(&style.fill, style.fill_tint) else { continue };
|
||||
let [r, gg, b, _] = c.to_rgba8(1.0);
|
||||
rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255));
|
||||
let [r, gg, b, a] = c.to_rgba8(1.0);
|
||||
if composing {
|
||||
rc.set_paint(peniko::Color::from_rgba8(r, gg, b, a));
|
||||
} else {
|
||||
rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255));
|
||||
}
|
||||
let outline = db.outline(&g.face, g.gid);
|
||||
let skew =
|
||||
if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
@@ -1664,12 +1697,41 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
|
||||
if app.pane != app.focus_pane || (ui.ctx().text_edit_focused() && !resp.has_focus()) {
|
||||
return;
|
||||
}
|
||||
// The engine drops composed text when something else edits or the caret moves; the input
|
||||
// method must forget it too, or the next key would finish an accent that is gone.
|
||||
let composing = ui.data(|d| d.get_temp::<bool>(ime_composing_id())).unwrap_or(false);
|
||||
let interrupt = composing && app.session.composition().is_none();
|
||||
if interrupt || !wants_text {
|
||||
ui.data_mut(|d| d.remove::<bool>(ime_composing_id()));
|
||||
}
|
||||
if wants_text {
|
||||
// Turn the OS input method on: macOS composes dead keys (⌥E then I types í) and
|
||||
// Japanese/Chinese input only for a window that asks for it. Without it ⌥E typed "e".
|
||||
let cursor_rect = ui.data(|d| d.get_temp(ime_caret_id())).unwrap_or(egui::Rect::from_min_size(rect.min, egui::Vec2::ZERO));
|
||||
ui.ctx().output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect, cursor_rect, should_interrupt_composition: interrupt });
|
||||
});
|
||||
}
|
||||
let evs = ui.input(|i| i.events.clone());
|
||||
for e in evs {
|
||||
match e {
|
||||
egui::Event::Text(t) if wants_text => {
|
||||
let _ = app.run("text.insert", json!({"text": t}));
|
||||
}
|
||||
// What is composed shows in the story, in the text's own format, until it commits.
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) if wants_text => {
|
||||
let on = !text.is_empty();
|
||||
if app.run("text.compose", json!({"text": text})).is_ok() {
|
||||
ui.data_mut(|d| d.insert_temp(ime_composing_id(), on));
|
||||
}
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(t)) if wants_text => {
|
||||
ui.data_mut(|d| d.remove::<bool>(ime_composing_id()));
|
||||
let _ = app.run("text.compose", json!({"text": ""}));
|
||||
if !t.is_empty() {
|
||||
let _ = app.run("text.insert", json!({"text": t}));
|
||||
}
|
||||
}
|
||||
egui::Event::Paste(t) if wants_text => {
|
||||
// Formatted when the system clipboard still holds what was copied here;
|
||||
// Shift (⇧⌘V) pastes without formatting.
|
||||
@@ -2342,6 +2404,37 @@ mod tests {
|
||||
assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h));
|
||||
}
|
||||
|
||||
/// ⌥E then I types "í" on a Mac: the input method composes the dead key's accent, which
|
||||
/// it does only for a window that turned it on. The canvas never did, so ⌥E typed "e".
|
||||
#[test]
|
||||
fn dead_keys_compose_through_the_input_method() {
|
||||
let (mut h, id) = typing("T");
|
||||
h.state_mut().run("text.placeCaret", json!({"frame": id, "point": [390.0, 80.0]})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.output().platform_output.ime.is_some(), "a text caret turns the input method on");
|
||||
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "´".into(), active_range_chars: None }));
|
||||
h.run_steps(2);
|
||||
let text = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.stories.values().map(|s| s.text.clone()).collect::<String>();
|
||||
assert_eq!(text(&h), "T´", "the accent being composed shows in the story, in its format");
|
||||
assert!(h.state().session.composition().is_some(), "and is marked as composed, not typed");
|
||||
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: String::new(), active_range_chars: None }));
|
||||
h.event(egui::Event::Ime(egui::ImeEvent::Commit("í".into())));
|
||||
h.run_steps(2);
|
||||
assert_eq!(text(&h), "Tí");
|
||||
assert!(h.state().session.composition().is_none());
|
||||
// Moving the caret mid-composition drops the accent and tells the input method so.
|
||||
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "˜".into(), active_range_chars: None }));
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("text.move", json!({"dir": "left"})).unwrap();
|
||||
h.step();
|
||||
assert_eq!(text(&h), "Tí");
|
||||
assert!(h.output().platform_output.ime.as_ref().is_some_and(|i| i.should_interrupt_composition));
|
||||
// Another tool takes no text: the input method is off.
|
||||
h.state_mut().run("tool.select", json!({"tool": "selection"})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.output().platform_output.ime.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rulers_open_no_units_menu_without_a_document() {
|
||||
let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()));
|
||||
|
||||
Reference in New Issue
Block a user