mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
M0: preserve IME typing sessions and simplify input handling
This commit is contained in:
+100
-83
@@ -21,7 +21,7 @@
|
||||
//! 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 std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
@@ -74,7 +74,7 @@ type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
|
||||
static NEXT_EDIT_SESSION: Mutex<u64> = Mutex::new(0);
|
||||
|
||||
fn next_edit_session() -> u64 {
|
||||
let mut next = NEXT_EDIT_SESSION.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut next = NEXT_EDIT_SESSION.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let session = next.checked_add(1).unwrap_or(u64::MAX);
|
||||
*next = session;
|
||||
session
|
||||
@@ -103,8 +103,8 @@ pub struct TypeTool {
|
||||
typing: Option<Typing>,
|
||||
preedit: Option<Preedit>,
|
||||
/// A command from outside the tool (`settle_interaction`, an external edit while typing) ended
|
||||
/// the typing session, while the text stays open for editing. The next IME commit then starts
|
||||
/// a fresh session over that edit, so it is application-origin, never `InputMethod`.
|
||||
/// the typing session, while the text stays open for editing. A commit over that edit is
|
||||
/// application-origin unless an IME deletion has already begun a fresh typing session.
|
||||
typing_ended: bool,
|
||||
/// Where the button went down, and where new type goes from there (snapped to Smart Guides).
|
||||
press: Option<(Point, Point)>,
|
||||
@@ -434,6 +434,7 @@ impl TypeTool {
|
||||
}
|
||||
if self.typing.is_none() {
|
||||
out.push(Action::Begin("Typing".into()));
|
||||
self.typing_ended = false;
|
||||
}
|
||||
let len = match &self.typing {
|
||||
Some(ty) => edit::runs_len(&ty.cur),
|
||||
@@ -928,15 +929,18 @@ impl Tool for TypeTool {
|
||||
if self.editing.is_none() {
|
||||
return true;
|
||||
}
|
||||
let normalized: String =
|
||||
s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect();
|
||||
if normalized != s {
|
||||
return false;
|
||||
}
|
||||
if normalized.is_empty() {
|
||||
if s.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if cx.doc.setup.typographers_quotes && matches!(normalized.as_str(), "\"" | "'") {
|
||||
// A straight quote's direction depends on the character before the insertion. During a
|
||||
// commit this check still sees the pre-edit at the caret, while `text_input` replaces it
|
||||
// from its start; with a custom pair whose opening quote is straight, that can look
|
||||
// verbatim here and then be rewritten. Never claim verbatim for one.
|
||||
if cx.doc.setup.typographers_quotes && matches!(s, "\"" | "'") {
|
||||
return false;
|
||||
}
|
||||
let Some(normalized) = self.input_text(cx, s) else { return false };
|
||||
if normalized != s {
|
||||
return false;
|
||||
}
|
||||
let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect();
|
||||
@@ -1211,78 +1215,7 @@ impl Tool for TypeTool {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let removed = preedit.as_ref().map_or(0, |r| r.end - r.start);
|
||||
let logical_len = len - removed;
|
||||
let to_logical = |byte: usize| -> usize {
|
||||
match &preedit {
|
||||
Some(r) if byte > r.start && byte < r.end => r.start,
|
||||
Some(r) if byte >= r.end => byte - removed,
|
||||
_ => byte,
|
||||
}
|
||||
};
|
||||
let cursor = to_logical(caret);
|
||||
let anchor = to_logical(anchor);
|
||||
let selection_start = cursor.min(anchor);
|
||||
let selection_end = cursor.max(anchor);
|
||||
let selection_len = selection_end - selection_start;
|
||||
if selection_len > max_bytes {
|
||||
return ImeSurrounding::Unavailable("the selection is wider than the IME surrounding-text limit");
|
||||
}
|
||||
let budget = max_bytes - selection_len;
|
||||
let before_available = selection_start;
|
||||
let after_available = logical_len - selection_end;
|
||||
let mut before = (budget / 2).min(before_available);
|
||||
let mut after = (budget - before).min(after_available);
|
||||
before = (budget - after).min(before_available);
|
||||
after = (budget - before).min(after_available);
|
||||
let mut window_start = selection_start - before;
|
||||
let mut window_end = selection_end + after;
|
||||
let logical_boundary = |byte: usize| -> bool {
|
||||
match &preedit {
|
||||
Some(r) if byte == r.start => true,
|
||||
Some(r) if byte < r.start => plain_is_boundary(&segments, byte),
|
||||
Some(_) => plain_is_boundary(&segments, byte + removed),
|
||||
None => plain_is_boundary(&segments, byte),
|
||||
}
|
||||
};
|
||||
while window_start < selection_start && !logical_boundary(window_start) {
|
||||
window_start += 1;
|
||||
}
|
||||
while selection_end < window_end && !logical_boundary(window_end) {
|
||||
window_end -= 1;
|
||||
}
|
||||
let to_original = |byte: usize| -> usize {
|
||||
match &preedit {
|
||||
Some(r) if byte > r.start => byte + removed,
|
||||
_ => byte,
|
||||
}
|
||||
};
|
||||
let original_start = to_original(window_start);
|
||||
let original_end = to_original(window_end);
|
||||
let mut text = String::with_capacity(window_end - window_start);
|
||||
match &preedit {
|
||||
Some(r) => {
|
||||
if window_start < r.start {
|
||||
let first_end = original_end.min(r.start);
|
||||
if original_start < first_end {
|
||||
copy_plain_range(&segments, original_start, first_end, &mut text);
|
||||
}
|
||||
}
|
||||
if r.start < window_end {
|
||||
let second_start = r.end.max(original_start);
|
||||
if second_start < original_end {
|
||||
copy_plain_range(&segments, second_start, original_end, &mut text);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if original_start < original_end {
|
||||
copy_plain_range(&segments, original_start, original_end, &mut text);
|
||||
}
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(text.len(), window_end - window_start, "removing the pre-edit must preserve the logical window length");
|
||||
ImeSurrounding::Available { text, cursor: cursor - window_start, anchor: anchor - window_start }
|
||||
ime_surrounding_window(&segments, preedit, len, caret, anchor, max_bytes)
|
||||
}
|
||||
fn composing(&self) -> bool {
|
||||
self.preedit.is_some()
|
||||
@@ -1485,6 +1418,90 @@ impl Tool for TypeTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The logical IME surrounding window: the pre-edit is removed and the caret/anchor offsets
|
||||
/// are remapped around it. `preedit` has already been validated against `segments`.
|
||||
fn ime_surrounding_window(
|
||||
segments: &[&str],
|
||||
preedit: Option<Range<usize>>,
|
||||
len: usize,
|
||||
caret: usize,
|
||||
anchor: usize,
|
||||
max_bytes: usize,
|
||||
) -> ImeSurrounding {
|
||||
let removed = preedit.as_ref().map_or(0, |r| r.end - r.start);
|
||||
let logical_len = len - removed;
|
||||
let to_logical = |byte: usize| -> usize {
|
||||
match &preedit {
|
||||
Some(r) if byte > r.start && byte < r.end => r.start,
|
||||
Some(r) if byte >= r.end => byte - removed,
|
||||
_ => byte,
|
||||
}
|
||||
};
|
||||
let cursor = to_logical(caret);
|
||||
let anchor = to_logical(anchor);
|
||||
let selection_start = cursor.min(anchor);
|
||||
let selection_end = cursor.max(anchor);
|
||||
let selection_len = selection_end - selection_start;
|
||||
if selection_len > max_bytes {
|
||||
return ImeSurrounding::Unavailable("the selection is wider than the IME surrounding-text limit");
|
||||
}
|
||||
let budget = max_bytes - selection_len;
|
||||
let before_available = selection_start;
|
||||
let after_available = logical_len - selection_end;
|
||||
let mut before = (budget / 2).min(before_available);
|
||||
let mut after = (budget - before).min(after_available);
|
||||
before = (budget - after).min(before_available);
|
||||
after = (budget - before).min(after_available);
|
||||
let mut window_start = selection_start - before;
|
||||
let mut window_end = selection_end + after;
|
||||
let logical_boundary = |byte: usize| -> bool {
|
||||
match &preedit {
|
||||
Some(r) if byte == r.start => true,
|
||||
Some(r) if byte < r.start => plain_is_boundary(segments, byte),
|
||||
Some(_) => plain_is_boundary(segments, byte + removed),
|
||||
None => plain_is_boundary(segments, byte),
|
||||
}
|
||||
};
|
||||
while window_start < selection_start && !logical_boundary(window_start) {
|
||||
window_start += 1;
|
||||
}
|
||||
while selection_end < window_end && !logical_boundary(window_end) {
|
||||
window_end -= 1;
|
||||
}
|
||||
let to_original = |byte: usize| -> usize {
|
||||
match &preedit {
|
||||
Some(r) if byte > r.start => byte + removed,
|
||||
_ => byte,
|
||||
}
|
||||
};
|
||||
let original_start = to_original(window_start);
|
||||
let original_end = to_original(window_end);
|
||||
let mut text = String::with_capacity(window_end - window_start);
|
||||
match &preedit {
|
||||
Some(r) => {
|
||||
if window_start < r.start {
|
||||
let first_end = original_end.min(r.start);
|
||||
if original_start < first_end {
|
||||
copy_plain_range(segments, original_start, first_end, &mut text);
|
||||
}
|
||||
}
|
||||
if r.start < window_end {
|
||||
let second_start = r.end.max(original_start);
|
||||
if second_start < original_end {
|
||||
copy_plain_range(segments, second_start, original_end, &mut text);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if original_start < original_end {
|
||||
copy_plain_range(segments, original_start, original_end, &mut text);
|
||||
}
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(text.len(), window_end - window_start, "removing the pre-edit must preserve the logical window length");
|
||||
ImeSurrounding::Available { text, cursor: cursor - window_start, anchor: anchor - window_start }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "text_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -8,7 +8,8 @@ use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use super::show;
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, VectorcraftApp>;
|
||||
|
||||
|
||||
+174
-152
@@ -6,6 +6,7 @@ use craft_keymap::{Active, CommandRef};
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::cmd::clipboard::{Flavour, TEXT};
|
||||
use vectorcraft_engine::{Result as EngineResult, UiRequest, ViewInfo};
|
||||
use vectorcraft_tools::{Mods, SurroundingUnit, ToolKey};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
@@ -219,6 +220,22 @@ fn is_shortcut_chord(m: egui::Modifiers) -> bool {
|
||||
(m.command || m.ctrl) && !m.alt
|
||||
}
|
||||
|
||||
enum Seq {
|
||||
Text(String),
|
||||
Preedit(String, Option<std::ops::Range<usize>>),
|
||||
Commit(String),
|
||||
Erase(ToolKey, Mods),
|
||||
/// An input method replaces text around the composition (upstream's character counts or
|
||||
/// the fork's exact UTF-8 byte counts), then commits.
|
||||
DeleteSurrounding(usize, usize, SurroundingUnit),
|
||||
// The actual pointer/key still belongs to its existing handler. Its position in this
|
||||
// sequence must nevertheless interrupt an old composition before later terminal input.
|
||||
InterruptPointer,
|
||||
InterruptNavigation,
|
||||
InterruptCommand,
|
||||
ResetCompleted(egui::ImeResetId),
|
||||
}
|
||||
|
||||
/// Typed text and IME events, in the order they came, to the Type tool.
|
||||
fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
let view = app.view_info();
|
||||
@@ -233,21 +250,55 @@ fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
// 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.
|
||||
enum Seq {
|
||||
Text(String),
|
||||
Preedit(String, Option<std::ops::Range<usize>>),
|
||||
Commit(String),
|
||||
Erase(ToolKey, Mods),
|
||||
/// An input method replaces text around the composition (upstream's character counts or
|
||||
/// the fork's exact UTF-8 byte counts), then commits.
|
||||
DeleteSurrounding(usize, usize, SurroundingUnit),
|
||||
// The actual pointer/key still belongs to its existing handler. Its position in this
|
||||
// sequence must nevertheless interrupt an old composition before later terminal input.
|
||||
InterruptPointer,
|
||||
InterruptNavigation,
|
||||
InterruptCommand,
|
||||
ResetCompleted(egui::ImeResetId),
|
||||
let seq = type_sequence(ctx);
|
||||
let viewport = ctx.viewport_id();
|
||||
let mut old_composition_retired = 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
|
||||
// the stock descriptor. The post-UI finalizer binds the confirmed replacement.
|
||||
crate::ime::sync_incoming_before_ui(app, ctx);
|
||||
if let Seq::ResetCompleted(request) = &e {
|
||||
old_composition_retired |= crate::ime::complete(app, ctx, *request);
|
||||
continue;
|
||||
}
|
||||
if matches!(app.ime_compositions.get(&viewport), Some(crate::ime::IncomingComposition::Stale)) {
|
||||
match &e {
|
||||
// Accepted old payloads precede the fence even when their terminal arrives.
|
||||
// None of these events proves the local reset has reached its boundary.
|
||||
Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A pointer/navigation/command notice has not reached its real handler yet. Text and
|
||||
// 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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let preedit_empty = match &e {
|
||||
Seq::Preedit(text, _) => Some(text.is_empty()),
|
||||
_ => None,
|
||||
};
|
||||
let terminal_commit = matches!(&e, Seq::Commit(_));
|
||||
let from_ime = matches!(&e, Seq::Preedit(..) | Seq::Commit(_) | Seq::DeleteSurrounding(..));
|
||||
// Errors are already reported by the engine's interaction (a failed preview keeps it).
|
||||
let result = apply_type_event(app, ctx, view, e, shortcut_mods, was_composing, &mut old_composition_retired);
|
||||
if result.is_ok() && from_ime {
|
||||
if terminal_commit {
|
||||
app.ime_compositions.remove(&viewport);
|
||||
} else {
|
||||
crate::ime::remember_incoming(app, ctx, preedit_empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
old_composition_retired
|
||||
}
|
||||
|
||||
/// Take this frame's typed text, IME events and plain Backspace/Delete, in the order they arrived.
|
||||
fn type_sequence(ctx: &egui::Context) -> Vec<Seq> {
|
||||
let mut seq = vec![];
|
||||
ctx.input_mut(|i| {
|
||||
// Move the events out instead of cloning their strings: a keyboard hook that reinjects a
|
||||
@@ -321,159 +372,130 @@ fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
}
|
||||
i.events = kept;
|
||||
});
|
||||
let viewport = ctx.viewport_id();
|
||||
let mut old_composition_retired = 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
|
||||
// the stock descriptor. The post-UI finalizer binds the confirmed replacement.
|
||||
crate::ime::sync_incoming_before_ui(app, ctx);
|
||||
if let Seq::ResetCompleted(request) = &e {
|
||||
old_composition_retired |= crate::ime::complete(app, ctx, *request);
|
||||
continue;
|
||||
seq
|
||||
}
|
||||
|
||||
/// Apply one sequence item to the active tool; the caller filters stale/deferred events and
|
||||
/// turns a successful IME mutation into its receipt.
|
||||
fn apply_type_event(
|
||||
app: &mut VectorcraftApp,
|
||||
ctx: &egui::Context,
|
||||
view: ViewInfo,
|
||||
e: Seq,
|
||||
shortcut_mods: bool,
|
||||
was_composing: bool,
|
||||
old_composition_retired: &mut bool,
|
||||
) -> EngineResult<Vec<UiRequest>> {
|
||||
match e {
|
||||
Seq::InterruptPointer => {
|
||||
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||||
Ok(vec![])
|
||||
}
|
||||
if matches!(app.ime_compositions.get(&viewport), Some(crate::ime::IncomingComposition::Stale)) {
|
||||
match &e {
|
||||
// Accepted old payloads precede the fence even when their terminal arrives.
|
||||
// None of these events proves the local reset has reached its boundary.
|
||||
Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue,
|
||||
_ => {}
|
||||
Seq::InterruptNavigation => {
|
||||
// Active composition owns its navigation keys. Once cleared, application caret
|
||||
// navigation interrupts its still-pending terminal event, even in this batch.
|
||||
if !app.session.tool_composing() {
|
||||
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
// A pointer/navigation/command notice has not reached its real handler yet. Text and
|
||||
// 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,
|
||||
_ => {}
|
||||
Seq::InterruptCommand => {
|
||||
if crate::ime::blocked(app, ctx) {
|
||||
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
let preedit_empty = match &e {
|
||||
Seq::Preedit(text, _) => Some(text.is_empty()),
|
||||
_ => None,
|
||||
};
|
||||
let terminal_commit = matches!(&e, Seq::Commit(_));
|
||||
let from_ime = matches!(&e, Seq::Preedit(..) | Seq::Commit(_) | Seq::DeleteSurrounding(..));
|
||||
// Errors are already reported by the engine's interaction (a failed preview keeps it).
|
||||
let result = match e {
|
||||
Seq::InterruptPointer => {
|
||||
old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||||
Ok(vec![])
|
||||
Seq::ResetCompleted(_) => Ok(vec![]),
|
||||
// Keys the IME passed through come as text only when nothing is marked. This is an
|
||||
// application edit (also a hook IME's reinjected characters): `Other` dominates.
|
||||
Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
let r = app.session.tool_text(&t, view);
|
||||
if r.is_ok() {
|
||||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||||
}
|
||||
Seq::InterruptNavigation => {
|
||||
// Active composition owns its navigation keys. Once cleared, application caret
|
||||
// navigation interrupts its still-pending terminal event, even in this batch.
|
||||
if !app.session.tool_composing() {
|
||||
old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
Seq::InterruptCommand => {
|
||||
if crate::ime::blocked(app, ctx) {
|
||||
old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
Seq::ResetCompleted(_) => Ok(vec![]),
|
||||
// Keys the IME passed through come as text only when nothing is marked. This is an
|
||||
// application edit (also a hook IME's reinjected characters): `Other` dominates.
|
||||
Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
let r = app.session.tool_text(&t, view);
|
||||
if r.is_ok() {
|
||||
r
|
||||
}
|
||||
Seq::Text(_) => Ok(vec![]),
|
||||
// A pre-edit is not committed text: it changes neither the outgoing snapshot (the
|
||||
// engine query removes it) nor the cause.
|
||||
Seq::Preedit(text, active_range_chars) => {
|
||||
app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty());
|
||||
app.session.tool_preedit(&text, active_range_chars, view)
|
||||
}
|
||||
// A bare line break confirms the composition (Enter): the marked text, not a newline.
|
||||
// It is an accepted IME commit of that marked text when the tool inserts the marked
|
||||
// text unchanged; a commit the tool rewrites (a quoted or filtered marked text) is an
|
||||
// application edit instead.
|
||||
Seq::Commit(t) if t == "\n" || t == "\r" => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
let composing = app.session.tool_composing();
|
||||
let verbatim = app.session.tool_ime_session_matches(view)
|
||||
&& match app.ime_marked.as_deref() {
|
||||
Some(m) => app.session.tool_text_is_verbatim(m, view),
|
||||
None => true,
|
||||
};
|
||||
keep_marked_text(app);
|
||||
if composing {
|
||||
if verbatim {
|
||||
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
|
||||
} else {
|
||||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||||
}
|
||||
r
|
||||
}
|
||||
Seq::Text(_) => Ok(vec![]),
|
||||
// A pre-edit is not committed text: it changes neither the outgoing snapshot (the
|
||||
// engine query removes it) nor the cause.
|
||||
Seq::Preedit(text, active_range_chars) => {
|
||||
app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty());
|
||||
app.session.tool_preedit(&text, active_range_chars, view)
|
||||
}
|
||||
// A bare line break confirms the composition (Enter): the marked text, not a newline.
|
||||
// It is an accepted IME commit of that marked text when the tool inserts the marked
|
||||
// text unchanged; a commit the tool rewrites (a quoted or filtered marked text) is an
|
||||
// application edit instead.
|
||||
Seq::Commit(t) if t == "\n" || t == "\r" => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
let composing = app.session.tool_composing();
|
||||
let verbatim = app.session.tool_ime_session_matches(view)
|
||||
&& match app.ime_marked.as_deref() {
|
||||
Some(m) => app.session.tool_text_is_verbatim(m, view),
|
||||
None => true,
|
||||
};
|
||||
keep_marked_text(app);
|
||||
if composing {
|
||||
if verbatim {
|
||||
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
|
||||
} else {
|
||||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
// An accepted IME commit: stamp the bounded committed context immediately before and
|
||||
// after, so only a real mutation (not a no-op, rejection or failure) earns the receipt.
|
||||
// A commit the tool rewrites (Typographer's Quotes, a filtered control character) is an
|
||||
// application edit and must never earn `InputMethod`.
|
||||
Seq::Commit(t) => {
|
||||
app.ime_marked = None;
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
// A document edit since the composition means this commit starts a fresh typing
|
||||
// session: that reset is application-side, so it cannot earn `InputMethod`.
|
||||
let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view);
|
||||
let r = app.session.tool_text(&t, view);
|
||||
if r.is_ok() {
|
||||
if verbatim {
|
||||
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
|
||||
} else {
|
||||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
// An accepted IME commit: stamp the bounded committed context immediately before and
|
||||
// after, so only a real mutation (not a no-op, rejection or failure) earns the receipt.
|
||||
// A commit the tool rewrites (Typographer's Quotes, a filtered control character) is an
|
||||
// application edit and must never earn `InputMethod`.
|
||||
Seq::Commit(t) => {
|
||||
app.ime_marked = None;
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
// A document edit since the composition means this commit starts a fresh typing
|
||||
// session: that reset is application-side, so it cannot earn `InputMethod`.
|
||||
let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view);
|
||||
let r = app.session.tool_text(&t, view);
|
||||
if r.is_ok() {
|
||||
if verbatim {
|
||||
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
|
||||
} else {
|
||||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||||
}
|
||||
}
|
||||
r
|
||||
r
|
||||
}
|
||||
// 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.
|
||||
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);
|
||||
match &r {
|
||||
Err(e) => app.status(e.to_string()),
|
||||
Ok(_) => crate::ime::note_application_edit(app, ctx, view, target_before, before),
|
||||
}
|
||||
// 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.
|
||||
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);
|
||||
match &r {
|
||||
Err(e) => app.status(e.to_string()),
|
||||
Ok(_) => crate::ime::note_application_edit(app, ctx, view, target_before, before),
|
||||
}
|
||||
r
|
||||
}
|
||||
Seq::Erase(..) => Ok(vec![]),
|
||||
// Deleting around the composition starts the same typing session as the commit that
|
||||
// follows, so the replacement is one undo step; an error reaches the status bar. The
|
||||
// receipt covers only this accepted deletion.
|
||||
Seq::DeleteSurrounding(before, after, unit) => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let stamp_before = crate::ime::stamp(&mut app.session, view);
|
||||
let r = app.session.tool_ime_delete_surrounding(before, after, unit, view);
|
||||
match &r {
|
||||
Err(e) => app.status(e.to_string()),
|
||||
Ok(_) => crate::ime::note_ime_apply(app, ctx, view, target_before, stamp_before),
|
||||
}
|
||||
r
|
||||
}
|
||||
};
|
||||
if result.is_ok() && from_ime {
|
||||
if terminal_commit {
|
||||
app.ime_compositions.remove(&viewport);
|
||||
} else {
|
||||
crate::ime::remember_incoming(app, ctx, preedit_empty);
|
||||
r
|
||||
}
|
||||
Seq::Erase(..) => Ok(vec![]),
|
||||
// Deleting around the composition starts the same typing session as the commit that
|
||||
// follows, so the replacement is one undo step; an error reaches the status bar. The
|
||||
// receipt covers only this accepted deletion.
|
||||
Seq::DeleteSurrounding(before, after, unit) => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let stamp_before = crate::ime::stamp(&mut app.session, view);
|
||||
let r = app.session.tool_ime_delete_surrounding(before, after, unit, view);
|
||||
match &r {
|
||||
Err(e) => app.status(e.to_string()),
|
||||
Ok(_) => crate::ime::note_ime_apply(app, ctx, view, target_before, stamp_before),
|
||||
}
|
||||
r
|
||||
}
|
||||
}
|
||||
old_composition_retired
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
|
||||
@@ -1158,6 +1158,63 @@ fn an_ime_commit_the_document_rewrites_is_other() {
|
||||
assert_eq!(filtered.change_cause, egui::output::ImeChangeCause::Other, "filtering is not an IME insertion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ime_quote_commit_uses_the_character_before_its_pre_edit() {
|
||||
let (mut app, id) = app_with_text("a");
|
||||
// The pre-edit ends in an opening quote, but replacement starts after the letter `a`.
|
||||
app.run("document.setup", json!({"quotes": {"double": ["\"", "”"]}})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
ime_frame(&mut app, &ctx, vec![], |_| {});
|
||||
ime_frame(&mut app, &ctx, vec![], |_| {});
|
||||
begin_typing(&mut app, &ctx, id);
|
||||
app.session.set_tool_option("select", &json!({"start": 1, "end": 1}));
|
||||
ime_frame(&mut app, &ctx, vec![], |_| {});
|
||||
ime_frame(&mut app, &ctx, vec![preedit("\"", 1)], |_| {});
|
||||
let committed = ime_frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("\"".into()))], |_| {}).expect("still editing");
|
||||
assert_eq!(plain(&app, id), "a”", "the document inserts its closing quote");
|
||||
assert_eq!(committed.change_cause, egui::output::ImeChangeCause::Other, "a rewritten quote is not an unchanged IME insertion");
|
||||
}
|
||||
|
||||
/// An outside command can end the typing session (`endTyping`) while the Type tool still owns
|
||||
/// the IME. The IME's following `DeleteSurrounding` starts a new typing session; that session
|
||||
/// must clear the ended marker, so after its commit and a caret move the next ordinary IME
|
||||
/// commit is an accepted `InputMethod` mutation, not an application edit.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_after_end_typing_keeps_the_next_commit_input_method() {
|
||||
let (mut app, id) = app_with_text("abc");
|
||||
let ctx = egui::Context::default();
|
||||
ime_frame(&mut app, &ctx, vec![], |_| {});
|
||||
ime_frame(&mut app, &ctx, vec![], |_| {});
|
||||
begin_typing(&mut app, &ctx, id);
|
||||
app.session.set_tool_option("select", &json!({"start": 3, "end": 3}));
|
||||
ime_frame(&mut app, &ctx, vec![], |_| {});
|
||||
// A real typing session, settled by an outside command as `settle_interaction` does.
|
||||
ime_frame(&mut app, &ctx, vec![Event::Text("x".into())], |_| {}).expect("typing owns the IME");
|
||||
assert_eq!(plain(&app, id), "abcx");
|
||||
ime_frame(&mut app, &ctx, vec![], |app| {
|
||||
app.session.commit_interaction().expect("the typing preview is committed");
|
||||
app.session.set_tool_option("endTyping", &json!(true));
|
||||
});
|
||||
// The IME deletes the typed character and commits its replacement in the new session.
|
||||
let deleted = ime_frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
vec![Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), Event::Ime(egui::ImeEvent::Commit("Z".into()))],
|
||||
|_| {},
|
||||
)
|
||||
.expect("the Type tool still owns the IME");
|
||||
assert_eq!(plain(&app, id), "abcZ");
|
||||
assert_eq!(deleted.change_cause, egui::output::ImeChangeCause::InputMethod);
|
||||
// A caret move ends the deletion's session. The following IME commit starts a fresh session;
|
||||
// because no outside command ended that session, the commit is InputMethod.
|
||||
let left = egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
ime_frame(&mut app, &ctx, vec![left], |_| {});
|
||||
let committed =
|
||||
ime_frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("Q".into()))], |_| {}).expect("the Type tool still owns the IME");
|
||||
assert_eq!(plain(&app, id), "abcQZ");
|
||||
assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod, "a caret move is not an application-side reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reloaded_document_gets_a_new_ime_replacement_epoch() {
|
||||
let (mut app, id) = app_with_text("Same");
|
||||
|
||||
Reference in New Issue
Block a user