Type: Enter, arrows, Home, End, Tab and the clipboard apply in order with the frame's text (#2636)

* Type: a Backspace and the text typed after it in one frame apply in order

The Type tool received all of a frame's typed text and IME events
first, and the frame's Backspace, Delete, arrows, Home, End, Tab and
clipboard keys afterwards. Enter went to the tool before the text, and
several Enters in one frame added one line break.

A phonetic keyboard tool can replace a character it typed by sending a
Backspace and the new character: ন for n, then a Backspace and ং when g
follows. When the Backspace and ং arrived in one frame, ং was inserted
and then deleted, so ন was left where ং belonged (#2615).

route_type_input now takes the frame's text, IME events, editing keys,
Enter and clipboard events in one pass, in the order they came. The
Type tool still drops the keys of a frame that began with IME marked
text, and the keys that come while the IME composes. A key or clipboard
event that comes before a new composition in the same frame is now
applied instead of dropped, and an Enter in the same frame as an Escape
no longer types a line break.

* Type: a command bound to Cmd+Enter runs without typing a line break

While the Type tool edits text, Enter went to the tool as a line break
before the shortcuts ran, so a command bound to Cmd/Ctrl+Enter also
typed a line break. Enter now types a line break only when no command
is bound to its chord. Plain Enter, and Cmd+Enter with nothing bound
to it, type a line break as before.

* Type: Enter, arrows, Home, End, Tab and the clipboard go in type_text's ordered pass

Enter, the arrows, Home, End, Tab, Backspace and Delete with modifiers,
Type > Bold and Italic, and the clipboard events now join the ordered
pass in type_text that takes text, IME events and plain Backspace and
Delete, instead of a separate pass that replaced it. route_type_input is
split into type_input, which takes one event, and apply_type_input.
Every event the Type tool takes, text and IME events included, is
removed from the input.

An Enter press whose chord a command is bound to stays in the input for
the shortcuts. The check uses chord_runs_a_command, which runs_a_command
now calls for each press, so the separate Cmd+Enter check in
route_type_input is removed. A plain Enter and a bound Cmd+Enter in one
frame type one line break and run the command.

hook_ime_backspace_and_reinject_builds_vietnamese covers a Backspace and
the text after it in one frame, so the Bangla test is removed. The order
test covers Home, End, Tab, a paste, and a key that comes before a
composition starts in the same frame.

* Type: editing keys after an IME commit apply in the same frame

In a frame that began with a composition, the Type tool dropped every
editing key, also one that came after the IME committed its text, so a
frame of a commit, ← and a letter typed the letter after the committed
text without moving the caret. Editing keys after a commit in that
frame now apply in order. A key that comes while the composition is
still open, and an Enter in a frame that began composing, still belong
to the IME.

* Type: Shift+Enter types a soft return when keys apply in order with the text

The ordered Type tool input passed Enter to the tool without its modifiers, so Shift+Enter typed a line break instead of the soft return main added. Enter now carries the key's modifiers.

* Type: test Home, Tab and Backspace against the frame's earlier text

A new test types text, Home and text in one frame, Tab inside a line,
and a paste followed by Backspace, and checks that each acts on the
text the earlier events left. It fails on main, where those keys apply
after all of the frame's text.

The Enter and Escape loop in shortcuts::handle skips Enter with
`skip` instead of an `if`, which keeps the function under main's
cognitive-complexity limit.
This commit is contained in:
.CooCoo
2026-11-05 22:22:47 +01:00
committed by GitHub
parent 80819ed7df
commit 36c73f7bb9
5 changed files with 305 additions and 129 deletions
+135
View File
@@ -6234,6 +6234,141 @@ fn ime_edge_orders_never_eat_committed_text_or_lose_the_composition() {
assert_eq!(plain(&app, id), "雅がくらくか");
}
/// Starts editing new point type with the Type tool; returns its id.
fn start_typing(app: &mut VectorcraftApp, ctx: &egui::Context) -> u64 {
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
typing_frame(app, ctx, vec![]);
app.select_tool("type");
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let at = xf.to_screen(Point::new(100.0, 100.0));
let click =
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
typing_frame(app, ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
typing_frame(app, ctx, vec![click(at, false)]);
assert!(app.session.tool_wants_text());
app.session.active().unwrap().selection.objects[0].0
}
/// Returns a key press and its release.
fn tap(key: egui::Key) -> [egui::Event; 2] {
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() })
}
/// Arrows, Home, End, Tab, Enter and clipboard events go to the Type tool in order with the text
/// of the same frame, and each Enter types a line break.
#[test]
fn the_type_tool_takes_text_and_editing_keys_in_the_order_they_came() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.prefs.placeholder_text = false;
let ctx = egui::Context::default();
let id = start_typing(&mut app, &ctx);
let text = |t: &str| egui::Event::Text(t.into());
typing_frame(&mut app, &ctx, vec![text("ax")]);
// ← moves the caret before the text that follows it goes in.
typing_frame(&mut app, &ctx, [tap(egui::Key::ArrowLeft).to_vec(), vec![text("b")]].concat());
assert_eq!(plain(&app, id), "abx");
// Home and End move the caret before the text that follows each goes in.
typing_frame(&mut app, &ctx, [tap(egui::Key::Home).to_vec(), vec![text("0")], tap(egui::Key::End).to_vec(), vec![text("9")]].concat());
assert_eq!(plain(&app, id), "0abx9");
// Tab and a paste go in between the texts around them.
let paste = egui::Event::Paste("p".into());
typing_frame(&mut app, &ctx, [vec![text("t")], tap(egui::Key::Tab).to_vec(), vec![text("u"), paste, text("q")]].concat());
assert_eq!(plain(&app, id), "0abx9t\tupq");
// Enter types its line break between the texts around it, once for each press.
typing_frame(&mut app, &ctx, [vec![text("1")], tap(egui::Key::Enter).to_vec(), tap(egui::Key::Enter).to_vec(), vec![text("2")]].concat());
assert_eq!(plain(&app, id), "0abx9t\tupq1\n\n2");
// A key that comes before a composition starts in the same frame still moves the caret.
typing_frame(&mut app, &ctx, [tap(egui::Key::Home).to_vec(), vec![preedit("k", 1)]].concat());
typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("k".into()))]);
assert_eq!(plain(&app, id), "0abx9t\tupq1\n\nk2");
}
/// Enter keeps each key event's modifiers while sharing a frame with typed text.
#[test]
fn shift_enter_types_a_soft_return_in_order_with_plain_enter() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.prefs.placeholder_text = false;
let ctx = egui::Context::default();
let id = start_typing(&mut app, &ctx);
let text = |t: &str| egui::Event::Text(t.into());
let enter =
|modifiers| [true, false].map(|pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers });
typing_frame(
&mut app,
&ctx,
[vec![text("a")], enter(egui::Modifiers::SHIFT).to_vec(), vec![text("b")], enter(egui::Modifiers::NONE).to_vec(), vec![text("c")]].concat(),
);
assert_eq!(plain(&app, id), "a\u{2028}b\nc");
}
/// Within one frame, Home between two texts, Tab inside a line, and Backspace after a paste each
/// act on the text as the events before them left it.
#[test]
fn home_tab_and_backspace_act_on_the_text_before_them_in_the_frame() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.prefs.placeholder_text = false;
let ctx = egui::Context::default();
let id = start_typing(&mut app, &ctx);
let text = |t: &str| egui::Event::Text(t.into());
typing_frame(&mut app, &ctx, vec![text("abc")]);
typing_frame(&mut app, &ctx, [vec![text("d")], tap(egui::Key::Home).to_vec(), vec![text("0")]].concat());
assert_eq!(plain(&app, id), "0abcd");
typing_frame(&mut app, &ctx, [tap(egui::Key::ArrowRight).to_vec(), tap(egui::Key::Tab).to_vec(), vec![text("t")]].concat());
assert_eq!(plain(&app, id), "0a\ttbcd");
typing_frame(&mut app, &ctx, [tap(egui::Key::End).to_vec(), vec![egui::Event::Paste("pq".into())], tap(egui::Key::Backspace).to_vec()].concat());
assert_eq!(plain(&app, id), "0a\ttbcdp");
}
/// In a frame that began composing, an editing key after the IME's commit applies before the text
/// that follows it; a key before the commit and an Enter after it stay the IME's.
#[test]
fn editing_keys_after_an_ime_commit_apply_in_the_same_frame() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.prefs.placeholder_text = false;
let ctx = egui::Context::default();
let id = start_typing(&mut app, &ctx);
let text = |t: &str| egui::Event::Text(t.into());
let commit = |t: &str| egui::Event::Ime(egui::ImeEvent::Commit(t.into()));
typing_frame(&mut app, &ctx, vec![text("ab")]);
typing_frame(&mut app, &ctx, vec![preedit("漢", 1)]);
typing_frame(&mut app, &ctx, [vec![preedit("", 0), commit("漢")], tap(egui::Key::ArrowLeft).to_vec(), vec![text("x")]].concat());
assert_eq!(plain(&app, id), "abx漢");
typing_frame(&mut app, &ctx, vec![preedit("字", 1)]);
let keys = [tap(egui::Key::ArrowLeft).to_vec(), vec![preedit("", 0), commit("字")], tap(egui::Key::Enter).to_vec()];
typing_frame(&mut app, &ctx, keys.concat());
assert_eq!(plain(&app, id), "abx字漢");
}
/// Cmd/Ctrl+Enter bound to a command runs the command and types no line break, also when a plain
/// Enter comes in the same frame. Unbound, it types a line break as Enter does.
#[test]
fn a_command_on_cmd_enter_runs_without_typing_a_line_break() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.prefs.placeholder_text = false;
let ctx = egui::Context::default();
let id = start_typing(&mut app, &ctx);
let cmd_enter = [true, false].map(|pressed| egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed,
repeat: false,
modifiers: egui::Modifiers::COMMAND,
});
typing_frame(&mut app, &ctx, vec![egui::Event::Text("a".into())]);
typing_frame(&mut app, &ctx, cmd_enter.to_vec());
assert_eq!(plain(&app, id), "a\n", "nothing is bound to Cmd+Enter");
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.grid", "Cmd+Enter");
crate::shortcut_editor::sync(&app.ui);
let grid = app.ui.view.grid;
typing_frame(&mut app, &ctx, cmd_enter.to_vec());
let ran = (plain(&app, id), app.ui.view.grid);
typing_frame(&mut app, &ctx, [tap(egui::Key::Enter).to_vec(), cmd_enter.to_vec()].concat());
app.ui.shortcut_overrides.clear();
crate::shortcut_editor::sync(&app.ui);
assert_eq!((ran.0.as_str(), ran.1), ("a\n", !grid), "Cmd+Enter runs Show Grid only");
assert_eq!((plain(&app, id).as_str(), app.ui.view.grid), ("a\n\n", grid), "Enter types a line break and Cmd+Enter runs Show Grid");
}
/// Reset to a blank document, select the Type tool and click document point (100, 100), returning
/// the screen position used by IME fixtures.
fn type_at(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 {
+80 -76
View File
@@ -651,7 +651,7 @@ pub(crate) fn intercept_text_command(app: &mut VectorcraftApp, id: &str) -> Opti
Some(Ok(Value::Null))
}
enum TextInput {
pub(crate) enum TextInput {
Key(ToolKey, Mods),
Copy,
Cut,
@@ -659,83 +659,87 @@ enum TextInput {
Face(Face),
}
/// Route editing keys (with modifiers), clipboard events and Cmd+C/X/V to the Type tool (Cmd+A is
/// left to the Select All shortcut, which selects the text being edited).
pub(crate) fn route_type_input(app: &mut VectorcraftApp, ctx: &egui::Context) {
/// Takes an editing key (with modifiers), a clipboard event or Cmd+C/X/V for the Type tool into
/// `todo` (Cmd+A is left to the Select All shortcut, which selects the text being edited). Returns
/// false for an event the tool takes, which is then removed from the input.
pub(crate) fn type_input(e: &egui::Event, todo: &mut Vec<TextInput>) -> bool {
match e {
egui::Event::Copy => {
todo.push(TextInput::Copy);
false
}
egui::Event::Cut => {
todo.push(TextInput::Cut);
false
}
egui::Event::Paste(s) => {
todo.push(TextInput::Paste(Some(s.clone())));
false
}
egui::Event::Key { key, pressed, modifiers: m, .. } => {
let mods = Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl && !m.command, space: false };
let tk = match key {
Key::ArrowLeft => Some(ToolKey::Left),
Key::ArrowRight => Some(ToolKey::Right),
Key::ArrowUp => Some(ToolKey::Up),
Key::ArrowDown => Some(ToolKey::Down),
Key::Home => Some(ToolKey::Home),
Key::End => Some(ToolKey::End),
Key::Backspace => Some(ToolKey::Backspace),
Key::Delete => Some(ToolKey::Delete),
Key::Tab if !m.command => Some(ToolKey::Tab),
_ => None,
};
if let Some(tk) = tk {
if *pressed {
todo.push(TextInput::Key(tk, mods));
}
return false;
}
// Type › Bold and Italic, as page layout apps have them while text is edited.
if m.command && m.shift && !m.alt && matches!(key, Key::B | Key::I) {
if *pressed {
todo.push(TextInput::Face(if *key == Key::B { Face::Bold } else { Face::Italic }));
}
return false;
}
if m.command && !m.shift && !m.alt && matches!(key, Key::C | Key::X | Key::V) {
if *pressed {
todo.push(match key {
Key::C => TextInput::Copy,
Key::X => TextInput::Cut,
_ => TextInput::Paste(None),
});
}
return false;
}
true
}
_ => true,
}
}
/// Keeps `ctx` for the Type tool's clipboard, which Edit › Copy and Cut also write to.
pub(crate) fn keep_ctx(ctx: &egui::Context) {
CTX.get_or_init(|| ctx.clone());
let mut todo = vec![];
ctx.input_mut(|i| {
i.events.retain(|e| match e {
egui::Event::Copy => {
todo.push(TextInput::Copy);
false
}
/// Applies an editing key or clipboard event taken by [`type_input`] to the Type tool.
pub(crate) fn apply_type_input(app: &mut VectorcraftApp, t: TextInput) {
match t {
TextInput::Key(k, m) => text_key(app, k, m),
TextInput::Copy => {
text_copy(app);
}
TextInput::Cut => {
if text_copy(app) {
text_key(app, ToolKey::Delete, Mods::default());
}
egui::Event::Cut => {
todo.push(TextInput::Cut);
false
}
egui::Event::Paste(s) => {
todo.push(TextInput::Paste(Some(s.clone())));
false
}
egui::Event::Key { key, pressed, modifiers: m, .. } => {
let mods = Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl && !m.command, space: false };
let tk = match key {
Key::ArrowLeft => Some(ToolKey::Left),
Key::ArrowRight => Some(ToolKey::Right),
Key::ArrowUp => Some(ToolKey::Up),
Key::ArrowDown => Some(ToolKey::Down),
Key::Home => Some(ToolKey::Home),
Key::End => Some(ToolKey::End),
Key::Backspace => Some(ToolKey::Backspace),
Key::Delete => Some(ToolKey::Delete),
Key::Tab if !m.command => Some(ToolKey::Tab),
_ => None,
};
if let Some(tk) = tk {
if *pressed {
todo.push(TextInput::Key(tk, mods));
}
return false;
}
// Type › Bold and Italic, as page layout apps have them while text is edited.
if m.command && m.shift && !m.alt && matches!(key, Key::B | Key::I) {
if *pressed {
todo.push(TextInput::Face(if *key == Key::B { Face::Bold } else { Face::Italic }));
}
return false;
}
if m.command && !m.shift && !m.alt && matches!(key, Key::C | Key::X | Key::V) {
if *pressed {
todo.push(match key {
Key::C => TextInput::Copy,
Key::X => TextInput::Cut,
_ => TextInput::Paste(None),
});
}
return false;
}
true
}
_ => true,
})
});
for t in todo {
match t {
TextInput::Key(k, m) => text_key(app, k, m),
TextInput::Copy => {
text_copy(app);
}
TextInput::Cut => {
if text_copy(app) {
text_key(app, ToolKey::Delete, Mods::default());
}
}
TextInput::Paste(s) => text_paste(app, s, false),
TextInput::Face(f) => {
if let Err(e) = toggle_face(app, f) {
app.status(e);
}
}
TextInput::Paste(s) => text_paste(app, s, false),
TextInput::Face(f) => {
if let Err(e) = toggle_face(app, f) {
app.status(e);
}
}
}
+17 -28
View File
@@ -139,13 +139,15 @@ fn runs_a_command(i: &egui::InputState, key: Key, cmd_only: bool) -> bool {
_ => None,
})
.collect();
let shortcuts = all_shortcuts();
let bound = |m: &Modifiers| {
shortcuts
.iter()
.any(|(sc, ..)| sc.logical_key == key && (sc.modifiers.command || (!cmd_only && sc.modifiers.any())) && m.matches_logically(sc.modifiers))
};
!presses.is_empty() && presses.iter().all(bound)
!presses.is_empty() && presses.iter().all(|m| chord_runs_a_command(key, *m, cmd_only))
}
/// Is a command shortcut bound to `key` held with `m`, matched logically?
/// `cmd_only`: only chords with Cmd count.
fn chord_runs_a_command(key: Key, m: Modifiers, cmd_only: bool) -> bool {
all_shortcuts()
.iter()
.any(|(sc, ..)| sc.logical_key == key && (sc.modifiers.command || (!cmd_only && sc.modifiers.any())) && m.matches_logically(sc.modifiers))
}
/// Whether a key press runs a shortcut of the keymap in effect, the user's own included. The web
@@ -233,7 +235,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
let composing = app.session.tool_composing();
// Tool keys first (Enter/Escape end paths; arrows change polygon sides while dragging).
let busy = app.session.tool_busy();
for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)] {
// Enter in the Type tool types a line break, so it goes to the tool with the frame's text,
// below: the loop then skips Enter, the first key.
let enter_types = app.session.tool_wants_text();
for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)].into_iter().skip(usize::from(enter_types)) {
if !typing && !composing && ctx.input(|i| i.key_pressed(k)) {
// A chord a command is bound to (Cmd+Enter) runs only that command, in the shortcuts
// below, also when the tool claims the key. While the Type tool edits text only Cmd
@@ -507,27 +512,11 @@ fn type_tool_input(app: &mut VectorcraftApp, ctx: &egui::Context, composing: boo
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_))));
return;
}
// A deferred pointer/navigation notice has not established its destination yet. Its
// clipboard suffix must not cut or paste at the old caret; only the notice's own actual
// handler (kept in the raw vector) may run.
if crate::ime::deferred(app, ctx) {
ctx.input_mut(|input| {
input.events.retain(|event| match event {
egui::Event::Cut | egui::Event::Paste(_) => false,
egui::Event::Key { key: egui::Key::X | egui::Key::V, pressed: true, modifiers, .. }
if modifiers.command && !modifiers.shift && !modifiers.alt =>
{
false
}
_ => true,
})
});
}
crate::panels::actions::play_function_key(app, ctx);
// Editing keys with modifiers and the clipboard (Cmd+A is Select All's, below: the text).
crate::panels::character::route_type_input(app, ctx);
// Enter was already delivered above as ToolKey::Enter (newline). Only chords with the
// command key run commands: the rest type or edit.
// Cmd+A is Select All's, below: the text. Enter went to the tool in `type_text`, unless a
// command is bound to its chord, and so did the editing keys and the clipboard events (a
// cut or paste after a deferred pointer or navigation notice is dropped there). Only chords
// with the command key run commands: the rest type or edit.
let hits = dispatch(app, ctx, |hit| hit.trigger.chord.mods.cmd);
for c in hits {
run_hit(app, ctx, &c);
+1 -1
View File
@@ -1967,7 +1967,7 @@ fn parses() {
/// An IME replacement on the Type tool arrives as `DeleteSurroundingBytes` + `Commit` events
/// and is taken once, in order: the marked text is dropped, the committed text replaces it, and
/// the events never reach `route_type_input` as typed characters.
/// the events are never typed as characters.
#[test]
fn ime_delete_surrounding_bytes_edits_the_type_tool_once() {
use vectorcraft_tools::{PointerEvent, PointerKind};
+72 -24
View File
@@ -1,4 +1,5 @@
use super::{EngineResult, Key, Mods, SurroundingUnit, ToolKey, UiRequest, VectorcraftApp, ViewInfo};
use super::{EngineResult, Key, Mods, SurroundingUnit, ToolKey, UiRequest, VectorcraftApp, ViewInfo, chord_runs_a_command};
use crate::panels::character::TextInput;
/// Drop each key press made with Ctrl and Alt held that is followed by the text it typed. Firefox
/// on Windows reports AltGr as Ctrl and Alt held, so AltGr+L on a Polish layout arrives as a
@@ -51,9 +52,14 @@ enum Seq {
InterruptNavigation,
InterruptCommand,
ResetCompleted(egui::ImeResetId),
/// Enter, which types a line break.
Enter(Mods),
/// An editing key with modifiers, an arrow, Home, End, Tab or a clipboard event.
Edit(TextInput),
}
/// Typed text and IME events, in the order they came, to the Type tool.
/// Typed text, IME events, editing keys, Enter and clipboard events, in the order they came, to the
/// Type tool. Escape is handled before them, in [`handle`].
pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
let view = app.view_info();
// Switching apps mid-composition needs nothing: the macOS IME keeps its marked text and goes
@@ -62,14 +68,18 @@ pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
// A frame that began composing keeps its editing keys for the IME: a Backspace that empties the
// marked text must not then delete the committed character before it.
let was_composing = app.session.tool_composing();
crate::panels::character::keep_ctx(ctx);
// Text, IME events and plain Backspace/Delete are taken together, in the order they arrived, so
// a keyboard-hook IME (UniKey/OpenKey) whose tone key sends Backspace(s) and then re-injects
// characters in one frame interleaves correctly. Taken as two passes (all text, then the keys)
// the deletes land after the inserts and the diacritics are lost. Each handled event is removed
// so `route_type_input` below does not process the same Backspace/Delete again.
// the deletes land after the inserts and the diacritics are lost. Enter, the other editing keys
// and the clipboard events join them: ← or a paste lands between the texts around it, and each
// Enter types its own line break. Each handled event is removed from the input.
let seq = type_sequence(ctx);
let viewport = ctx.viewport_id();
let mut old_composition_retired = false;
// Set once the IME commits text in this frame.
let mut committed = false;
for e in seq {
// Before the UI, a changed composition or guard is settled `pending`: a token bound here
// could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to
@@ -91,10 +101,19 @@ pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
// erases after it belong to the old caret, not to the destination it will establish.
if crate::ime::deferred(app, ctx) {
match &e {
Seq::Text(_) | Seq::Erase(..) => continue,
// A cut or paste must not change the text at the old caret either.
Seq::Text(_) | Seq::Erase(..) | Seq::Edit(TextInput::Cut | TextInput::Paste(_)) => continue,
_ => {}
}
}
// Editing keys and clipboard events belong to the IME while it composes, and in a frame
// that began composing until the IME commits or the composition is retired.
if matches!(&e, Seq::Edit(_)) && (app.session.tool_composing() || (was_composing && !committed && !old_composition_retired)) {
continue;
}
if let Seq::Commit(t) = &e {
committed |= !t.is_empty() && t != "\n" && t != "\r";
}
let preedit_empty = match &e {
Seq::Preedit(text, _) => Some(text.is_empty()),
_ => None,
@@ -114,9 +133,11 @@ pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
old_composition_retired
}
/// Take this frame's typed text, IME events and plain Backspace/Delete, in the order they arrived.
/// Take this frame's typed text, IME events, editing keys, Enter and clipboard events, in the order
/// they arrived.
fn type_sequence(ctx: &egui::Context) -> Vec<Seq> {
let mut seq = vec![];
let mut edits = vec![];
ctx.input_mut(|i| {
// Move the events out instead of cloning their strings: a keyboard hook that reinjects a
// batch keeps the batch in `seq` only, not in both the input list and a second copy.
@@ -149,13 +170,20 @@ fn type_sequence(ctx: &egui::Context) -> Vec<Seq> {
// routed as a key or shortcut).
egui::Event::MiddleClickPaste { text, .. } => seq.push(Seq::Text(text)),
// Only unmodified Backspace/Delete interleave with injected text; word-delete and other
// modified forms stay with `route_type_input`.
// modified forms are taken with the other editing keys below.
egui::Event::Key { key: key @ (Key::Backspace | Key::Delete), pressed: true, modifiers: m, .. }
if !m.command && !m.alt && !m.ctrl =>
{
let tk = if key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete };
seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() }));
}
// A command bound to the chord (Cmd+Enter) runs instead of the line break, in the
// shortcuts after this.
egui::Event::Key { key: Key::Enter, pressed, modifiers: m, .. } if !chord_runs_a_command(Key::Enter, m, true) => {
if pressed {
seq.push(Seq::Enter(crate::canvas::mods(m, false)));
}
}
e @ egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => {
primary_down = pressed;
if pressed {
@@ -169,22 +197,25 @@ fn type_sequence(ctx: &egui::Context) -> Vec<Seq> {
}
kept.push(e);
}
e @ egui::Event::Key { key, pressed: true, modifiers, .. }
if matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End)
|| key == Key::A && modifiers.command =>
{
seq.push(Seq::InterruptNavigation);
kept.push(e);
e => {
let interrupt = match &e {
egui::Event::Key { key, pressed: true, modifiers, .. }
if matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End)
|| *key == Key::A && modifiers.command =>
{
Some(Seq::InterruptNavigation)
}
egui::Event::Key { pressed: true, modifiers, .. } if is_shortcut_chord(*modifiers) => Some(Seq::InterruptCommand),
egui::Event::Cut | egui::Event::Paste(_) => Some(Seq::InterruptCommand),
_ => None,
};
seq.extend(interrupt);
// The other editing keys and the clipboard events apply in this order too.
if crate::panels::character::type_input(&e, &mut edits) {
kept.push(e);
}
seq.extend(edits.drain(..).map(Seq::Edit));
}
e @ egui::Event::Key { pressed: true, modifiers, .. } if is_shortcut_chord(modifiers) => {
seq.push(Seq::InterruptCommand);
kept.push(e);
}
e @ (egui::Event::Cut | egui::Event::Paste(_)) => {
seq.push(Seq::InterruptCommand);
kept.push(e);
}
other => kept.push(other),
}
}
i.events = kept;
@@ -287,8 +318,8 @@ fn apply_type_event(
}
// While composing (or in a frame that began composing), editing keys belong to the IME.
Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => {
// Mirror `route_type_input`/`text_key`: surface an erase error in the status bar
// instead of discarding it, and report the successful edit as application-origin.
// Mirror `text_key`: surface an erase error in the status bar instead of discarding it,
// and report the successful edit as application-origin.
let target_before = crate::ime::target(&mut app.session, view);
let before = crate::ime::stamp(&mut app.session, view);
let r = app.session.tool_key(tk, m, view);
@@ -312,5 +343,22 @@ fn apply_type_event(
}
r
}
// An Enter that confirms the composition types no line break.
Seq::Enter(_) if was_composing || app.session.tool_composing() => Ok(vec![]),
Seq::Enter(m) => {
let target_before = crate::ime::target(&mut app.session, view);
let before = crate::ime::stamp(&mut app.session, view);
let r = app.session.tool_key(ToolKey::Enter, m, view);
if r.is_ok() {
crate::ime::note_application_edit(app, ctx, view, target_before, before);
}
crate::canvas::apply_requests(app, r);
Ok(vec![])
}
// `text_key` and `text_paste` report the edit as application-origin.
Seq::Edit(t) => {
crate::panels::character::apply_type_input(app, t);
Ok(vec![])
}
}
}