refactor(ui-egui): move src/panels/viewer_text.rs tests to src/panels/viewer_text/tests.rs

This commit is contained in:
aiongg
2026-10-26 21:49:11 +00:00
parent c4d5c9c499
commit 2f26ee2e24
2 changed files with 821 additions and 822 deletions
+1 -822
View File
@@ -1424,825 +1424,4 @@ fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, ma
}
#[cfg(test)]
mod tests {
//! Incoming IME edits on the custom text editor (the Type tool's own session, not a stock
//! `TextEdit`). Winit reports the bytes around the selection *before* the commit it belongs
//! to, so the deletion and the insert must share one undo step.
use super::*;
use effectcraft_engine::Session;
use effectcraft_engine::commands::text_edit::TextEdit;
use serde_json::json;
fn editor(text: &str, anchor: usize, caret: usize) -> (EffectcraftApp, LayerId) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "IME", "width": 160, "height": 90, "duration": 1})).unwrap();
let layer = s.execute("layer.newText", json!({"text": text})).unwrap()["layer"].as_u64().unwrap();
let lid = LayerId(layer);
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, ..Default::default() });
(EffectcraftApp::new(s), lid)
}
fn doc_text(app: &EffectcraftApp, lid: LayerId) -> String {
layer_doc(&app.session, lid).map(|d| d.text).unwrap_or_default()
}
fn feed(app: &mut EffectcraftApp, events: Vec<egui::Event>) {
let ctx = egui::Context::default();
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 180.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| keyboard(app, ui, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 180.0))));
out.textures_delta.clear();
}
#[test]
fn preedit_preview_is_bounded_on_a_character_boundary() {
let text = "é".repeat(MAX_IME_TEXT_BYTES);
let capped = bounded_preedit(text);
assert!(capped.len() <= MAX_IME_TEXT_BYTES);
assert!(capped.is_char_boundary(capped.len()));
assert_eq!(capped.len() % "é".len(), 0, "the cap never leaves half a character");
}
/// Ending the edit from another panel clears this viewport's pre-edit. The production path
/// used to resolve `viewport_id()` (a context read lock) inside `data_mut` (a write lock);
/// that nested context access deadlocked every pass that drew the viewer.
#[test]
fn ending_an_edit_from_another_panel_clears_the_preedit() {
let (mut app, lid) = editor("abc", 0, 0);
let session = app.session.state.text_edit.as_ref().map_or(1, |e| e.session);
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let viewport = ui.ctx().viewport_id();
let preedit = Preedit { owner: ImeOwner { layer: lid, session }, text: "x".into() };
ui.data_mut(|d| d.insert_temp(ime_preedit_id(viewport), preedit));
panel_clicked(&mut app, ui.ctx(), PanelKind::Timeline);
let viewport = ui.ctx().viewport_id();
assert!(ui.data(|d| d.get_temp::<Preedit>(ime_preedit_id(viewport))).is_none(), "the ended edit leaves no preview behind");
});
out.textures_delta.clear();
assert!(app.session.state.text_edit.is_none(), "a click in the Timeline ends the text edit");
}
#[test]
fn ime_delete_surrounding_bytes_and_the_commit_are_one_undo_step() {
// "café😀": the last character is four bytes, so a byte-count deletion that treated bytes
// as characters would corrupt the document.
let (mut app, lid) = editor("café😀", 5, 5);
feed(
&mut app,
vec![
egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 4, after_bytes: 0 }),
egui::Event::Ime(egui::ImeEvent::Commit("X".into())),
],
);
assert_eq!(doc_text(&app, lid), "caféX", "the emoji is replaced by the committed text");
app.session.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc_text(&app, lid), "café😀", "the replacement is one undo step");
}
#[test]
fn ime_delete_surrounding_chars_keeps_the_selection_for_the_commit() {
// Stock egui deletes the characters around the selection, not the selection itself: the
// commit that follows replaces "bcd", it does not insert next to a deleted document.
let (mut app, lid) = editor("abcdef", 1, 4);
feed(&mut app, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 99 })]);
assert_eq!(doc_text(&app, lid), "bcd", "one character before and everything after go");
let sel = app.session.state.text_edit.clone().expect("editing continues");
assert_eq!(sel.range(), 0..3, "the selected text is still selected");
// A deletion-only batch (no commit) leaves "bcd", not an empty document.
app.session.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc_text(&app, lid), "abcdef", "clamped deletion is still one undo step");
// Delete surround + commit: the commit replaces the kept selection.
let (mut app, lid) = editor("abcdef", 1, 4);
feed(
&mut app,
vec![
egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 99 }),
egui::Event::Ime(egui::ImeEvent::Commit("XY".into())),
],
);
assert_eq!(doc_text(&app, lid), "XY", "the commit replaces the selection");
app.session.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc_text(&app, lid), "abcdef", "the deletion and its commit are one undo step");
}
/// One UI pass with the editor focused, events applied, and the overlay's observation.
struct ImePass {
ctx: egui::Context,
viewport: egui::ViewportId,
rect: egui::Rect,
}
impl ImePass {
fn new(viewport: egui::ViewportId) -> Self {
let ctx = egui::Context::default();
install_hooks(&ctx);
Self { ctx, viewport, rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 180.0)) }
}
/// Run one `Context` frame. `overlay` is false for a pass in which the viewer (and so
/// the edit overlay) never drew; `discard` forces a second pass of the same frame.
fn frame(&mut self, app: &mut EffectcraftApp, events: Vec<egui::Event>, overlay: bool, discard: bool) -> Option<egui::output::IMEOutput> {
let ctx = self.ctx.clone();
let rect = self.rect;
let viewport = self.viewport;
let mut viewports = egui::ViewportIdMap::default();
viewports.insert(self.viewport, egui::ViewportInfo::default());
let raw = egui::RawInput { viewport_id: self.viewport, viewports, screen_rect: Some(rect), events, ..Default::default() };
let mut asked = false;
let mut out = ctx.run_ui(raw, |ui| {
begin_ui(ui.ctx());
ui.interact(rect, focus_id(), egui::Sense::focusable_noninteractive());
ui.memory_mut(|m| m.request_focus(focus_id()));
sync_ime_target(ui, app);
keyboard(app, ui, rect);
if overlay {
let pass = ui.ctx().cumulative_pass_nr_for(viewport);
ui.data_mut(|d| d.insert_temp(ime_geometry_id(viewport), (pass, rect, rect)));
}
finish_ime(app, ui);
if discard && !asked {
asked = true;
ui.ctx().request_discard("ime pass test");
}
});
out.textures_delta.clear();
out.platform_output.ime
}
}
/// One native floating window holding the Timeline's group and then the Viewer's.
fn two_group_window() -> craft_ui::docking::Floating<PanelKind> {
let groups = vec![craft_ui::docking::Group::new(vec![PanelKind::Timeline], 0), craft_ui::docking::Group::new(vec![PanelKind::Viewer(1)], 0)];
craft_ui::docking::Floating::stack(groups, [0.0, 0.0, 320.0, 360.0]).expect("two groups make a window")
}
impl ImePass {
/// One pass of a native window with a body that edits no text and the Viewer's editor,
/// in the order given, publishing as `NativeIme` decides or, with `every`, after each
/// body. Returns the descriptor and the preedit the pass ended with.
fn window(
&mut self,
app: &mut EffectcraftApp,
window: &craft_ui::docking::Floating<PanelKind>,
events: Vec<egui::Event>,
viewer_first: bool,
every: bool,
) -> (Option<egui::output::IMEOutput>, Option<String>) {
let ctx = self.ctx.clone();
let (rect, viewport) = (self.rect, self.viewport);
let mut viewports = egui::ViewportIdMap::default();
viewports.insert(viewport, egui::ViewportInfo::default());
let raw = egui::RawInput { viewport_id: viewport, viewports, screen_rect: Some(rect), events, ..Default::default() };
let mut preedit = None;
let mut out = ctx.run_ui(raw, |ui| {
let mut ime = crate::dock_ui::NativeIme::new(std::slice::from_ref(window), &[]);
let order = if viewer_first { [true, false] } else { [false, true] };
for viewer in order {
if ime.begin_body(viewport) {
begin_ui(ui.ctx());
}
if viewer {
ui.interact(rect, focus_id(), egui::Sense::focusable_noninteractive());
ui.memory_mut(|m| m.request_focus(focus_id()));
sync_ime_target(ui, app);
keyboard(app, ui, rect);
let pass = ui.ctx().cumulative_pass_nr_for(viewport);
ui.data_mut(|d| d.insert_temp(ime_geometry_id(viewport), (pass, rect, rect)));
}
if ime.publishes(viewport, ime_recorded(ui.ctx())) || every {
finish_ime(app, ui);
}
}
assert!(ime.unpublished().is_empty(), "the window published");
preedit = read_preedit(ui).map(|p| p.text);
});
out.textures_delta.clear();
(out.platform_output.ime, preedit)
}
}
#[test]
fn native_window_with_a_group_before_the_viewer_keeps_the_composition() {
let window = two_group_window();
let viewport = craft_ui::docking::DockArea::<PanelKind>::window_viewport(crate::dock_ui::area_id(), window.id);
let preview = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "é".into(), active_range_chars: None });
for viewer_first in [false, true] {
let (mut app, _) = editor("abcd", 4, 4);
let mut pass = ImePass::new(viewport);
let (first, preedit) = pass.window(&mut app, &window, vec![preview()], viewer_first, false);
let first = first.expect("the editor publishes");
assert_eq!(preedit.as_deref(), Some("é"));
for _ in 0..2 {
let (ime, preedit) = pass.window(&mut app, &window, vec![], viewer_first, false);
let ime = ime.expect("the editor keeps publishing");
assert!(!ime.should_interrupt_composition, "viewer first: {viewer_first}");
assert_eq!((ime.owner, ime.context_generation), (first.owner, first.context_generation), "the same IME context");
assert_eq!(preedit.as_deref(), Some("é"), "the preedit survives the other body");
}
}
// Publishing after the body drawn before the Viewer clears the preedit and interrupts.
let (mut app, _) = editor("abcd", 4, 4);
let mut pass = ImePass::new(viewport);
pass.window(&mut app, &window, vec![preview()], false, true);
let (ime, preedit) = pass.window(&mut app, &window, vec![], false, true);
assert!(ime.expect("the editor publishes").should_interrupt_composition);
assert_eq!(preedit, None);
// A window that drew fewer bodies than its layout counts publishes after every body in
// its next pass.
let mut ime = crate::dock_ui::NativeIme::new(std::slice::from_ref(&window), &[]);
ime.begin_body(viewport);
assert!(!ime.publishes(viewport, false));
let unpublished = ime.unpublished();
assert_eq!(unpublished, vec![viewport]);
let mut ime = crate::dock_ui::NativeIme::new(std::slice::from_ref(&window), &unpublished);
ime.begin_body(viewport);
assert!(ime.publishes(viewport, false));
}
fn ime_text(ime: &egui::output::IMEOutput) -> (&str, usize, usize) {
let p = ime.surrounding_text.available().expect("committed surrounding text");
(p.text.as_str(), p.cursor, p.anchor)
}
#[test]
fn discarded_custom_request_then_home_rearms_the_identical_caret() {
let (mut app, lid) = editor("abcd", 4, 4);
app.ui.start_screen = false;
app.ui.tool = Tool::Type;
app.ui.focused = PanelKind::Composition;
let ctx = egui::Context::default();
install_hooks(&ctx);
let mut frame = eframe::Frame::_new_kittest();
let raw = || egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), ..Default::default() };
for _ in 0..3 {
let mut output = ctx.run_ui(raw(), |ui| {
eframe::App::logic(&mut app, ui.ctx(), &mut frame);
app.draw(ui, &frame);
});
output.textures_delta.clear();
}
let mut input = raw();
input.events.push(egui::Event::Ime(egui::ImeEvent::Preedit { text: "é".into(), active_range_chars: None }));
let mut output = ctx.run_ui(input, |ui| {
eframe::App::logic(&mut app, ui.ctx(), &mut frame);
app.draw(ui, &frame);
});
output.textures_delta.clear();
assert!(output.platform_output.ime.is_some());
app.session.execute("text.setSelection", json!({"caret": 1})).unwrap();
let selection = app.session.state.text_edit.clone();
let mut passes = 0;
let mut discarded = None;
let mut output = ctx.run_ui(raw(), |ui| {
passes += 1;
if passes > 1 {
app.ui.start_screen = true;
}
eframe::App::logic(&mut app, ui.ctx(), &mut frame);
app.draw(ui, &frame);
if passes == 1 {
assert!(ui.ctx().output(|output| output.ime.is_some()));
discarded = read_reset(ui.ctx()).and_then(|guard| guard.request);
ui.ctx().request_discard("custom IME descriptor discarded before Home");
}
});
output.textures_delta.clear();
assert!(passes >= 2);
let first = discarded.expect("the actual discarded custom pass requested a fence");
assert!(output.platform_output.ime.is_none());
assert!(read_reset(&ctx).unwrap().suspended);
assert!(read_reset(&ctx).unwrap().request.is_none());
assert_eq!(app.session.state.text_edit, selection, "Home did not move the model caret");
app.ui.start_screen = false;
let mut output = ctx.run_ui(raw(), |ui| {
eframe::App::logic(&mut app, ui.ctx(), &mut frame);
app.draw(ui, &frame);
});
output.textures_delta.clear();
let second = output.platform_output.ime.unwrap().reset_request.expect("same caret reactivation publishes a new real ID");
assert_ne!(first, second);
let mut output = ctx.run_ui(raw(), |ui| {
eframe::App::logic(&mut app, ui.ctx(), &mut frame);
app.draw(ui, &frame);
});
output.textures_delta.clear();
assert!(output.platform_output.ime.unwrap().reset_request.is_none());
let mut input = raw();
input.events.extend([
egui::Event::ImeResetCompleted { request_id: first },
egui::Event::Ime(egui::ImeEvent::Commit("X".into())),
egui::Event::ImeResetCompleted { request_id: second },
egui::Event::Ime(egui::ImeEvent::Preedit { text: "が".into(), active_range_chars: None }),
egui::Event::Ime(egui::ImeEvent::Commit("が".into())),
]);
let mut output = ctx.run_ui(input, |ui| {
eframe::App::logic(&mut app, ui.ctx(), &mut frame);
app.draw(ui, &frame);
});
output.textures_delta.clear();
assert_eq!(doc_text(&app, lid), "aがbcd");
}
#[test]
fn viewport_fences_and_duplicate_completion_keep_their_exact_lifetime() {
let (mut app, lid) = editor("abcd", 4, 4);
let mut root = ImePass::new(egui::ViewportId::ROOT);
let mut detached = ImePass::new(egui::ViewportId::from_hash_of("effect-ime-detached"));
let preview = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "é".into(), active_range_chars: None });
root.frame(&mut app, vec![preview()], true, false);
detached.frame(&mut app, vec![preview()], true, false);
app.session.execute("text.setSelection", json!({"caret": 1})).unwrap();
let first = root.frame(&mut app, vec![], true, false).unwrap().reset_request.unwrap();
let second = detached.frame(&mut app, vec![], true, false).unwrap().reset_request.unwrap();
assert_ne!(first, second);
detached.frame(&mut app, vec![egui::Event::ImeResetCompleted { request_id: first }, egui::Event::Ime(egui::ImeEvent::Commit("X".into()))], true, false);
assert_eq!(doc_text(&app, lid), "abcd");
let pending = detached.ctx.data(|data| data.get_temp::<ResetGuard>(reset_id(detached.viewport))).unwrap().request;
assert_eq!(pending, Some(second), "a root marker cannot release the detached viewport's full ID");
let output = detached
.frame(
&mut app,
vec![
egui::Event::ImeResetCompleted { request_id: second },
preview(),
egui::Event::ImeResetCompleted { request_id: second },
egui::Event::Ime(egui::ImeEvent::Commit("が".into())),
],
true,
false,
)
.unwrap();
assert_eq!(doc_text(&app, lid), "aがbcd");
assert!(output.reset_request.is_none(), "a duplicate control cannot reconstruct the cleared guard");
// An actual context-generation change at unchanged selection still supersedes a wait.
root.frame(&mut app, vec![], true, false);
let replaced = root.frame(&mut app, vec![], false, false);
assert!(replaced.is_none());
let replacement = root.frame(&mut app, vec![], true, false).unwrap().reset_request.unwrap();
assert_ne!(first, replacement);
}
#[test]
fn ime_descriptor_sends_committed_text_and_preserves_a_reversed_selection() {
let (mut app, _) = editor("café😀", 2, 5);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let first = pass.frame(&mut app, vec![], true, false).expect("the focused editor owns IME");
assert_eq!(first.owner, ime_target(&app).expect("an edit target").owner);
assert!(first.context_generation > 0);
assert_eq!(ime_text(&first), ("café😀", 9, 2), "UTF-8 bytes, with the caret's direction kept");
assert_eq!(first.change_cause, egui::ImeChangeCause::Other, "starting a context is application origin");
assert_eq!((first.rect, first.cursor_rect), (pass.rect, pass.rect), "the real caret geometry");
let again = pass.frame(&mut app, vec![], true, false).expect("still editing");
assert_eq!(again.context_generation, first.context_generation, "an unchanged pass keeps the context");
assert_eq!(again.surrounding_text, first.surrounding_text);
}
#[test]
fn accepted_ime_commit_is_input_method_and_ordinary_typing_is_other() {
let (mut app, lid) = editor("ab", 2, 2);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let first = pass.frame(&mut app, vec![], true, false).expect("editing");
let generation = first.context_generation;
let commit = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), "ab日");
assert_eq!(ime_text(&commit), ("ab日", 5, 5));
assert_eq!(commit.change_cause, egui::ImeChangeCause::InputMethod, "only the accepted commit earns it");
assert_eq!(commit.context_generation, generation, "an ordinary text change is not a new context");
let typed = pass.frame(&mut app, vec![egui::Event::Text("x".into())], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), "ab日x");
assert_eq!(typed.change_cause, egui::ImeChangeCause::Other, "typing is application work");
assert_eq!(typed.context_generation, generation);
}
#[test]
fn ime_delete_and_commit_chain_is_input_method_with_a_reversed_selection() {
let (mut app, lid) = editor("abcdef", 4, 1);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![], true, false).expect("editing");
let deleted = pass
.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 1 })], true, false)
.expect("still editing");
assert_eq!(doc_text(&app, lid), "bcdf", "one character goes before and one after the selection");
assert_eq!(ime_text(&deleted), ("bcdf", 0, 3), "the selected text and its reversed direction stay");
assert_eq!(deleted.change_cause, egui::ImeChangeCause::InputMethod);
let committed = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("X".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), "Xf", "the commit replaces the kept selection");
assert_eq!(committed.change_cause, egui::ImeChangeCause::InputMethod, "the delete and commit are one accepted chain");
app.session.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc_text(&app, lid), "abcdef", "the replacement is one undo step");
}
#[test]
fn an_application_edit_in_the_same_frame_dominates_the_ime_cause() {
// Application edit before the IME commit.
let (mut app, lid) = editor("ab", 2, 2);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![], true, false).expect("editing");
let mixed = pass
.frame(&mut app, vec![egui::Event::Text("x".into()), egui::Event::Ime(egui::ImeEvent::Commit("日".into()))], true, false)
.expect("still editing");
assert_eq!(doc_text(&app, lid), "abx日");
assert_eq!(mixed.change_cause, egui::ImeChangeCause::Other, "the earlier application edit dominates");
// Application edit after the IME commit.
let (mut app, lid) = editor("ab", 2, 2);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![], true, false).expect("editing");
let mixed = pass
.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into())), egui::Event::Text("x".into())], true, false)
.expect("still editing");
assert_eq!(doc_text(&app, lid), "ab日x");
assert_eq!(mixed.change_cause, egui::ImeChangeCause::Other, "the later application edit dominates");
}
#[test]
fn preedit_is_excluded_from_the_committed_snapshot_and_keeps_the_context() {
let (mut app, lid) = editor("ab", 2, 2);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let first = pass.frame(&mut app, vec![], true, false).expect("editing");
let preview = pass
.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })], true, false)
.expect("still editing");
assert_eq!(doc_text(&app, lid), "ab", "the pre-edit never enters the document");
assert_eq!(ime_text(&preview), ("ab", 2, 2), "only committed text is sent");
assert_eq!(preview.context_generation, first.context_generation, "pre-edit updates keep the composition");
let commit = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("に".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), "abに");
assert_eq!(commit.change_cause, egui::ImeChangeCause::InputMethod, "clearing the pre-edit and committing is IME work");
assert_eq!(commit.context_generation, first.context_generation);
}
#[test]
fn empty_and_unavailable_surrounding_text_are_distinct() {
let (mut app, _) = editor("", 0, 0);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let empty = pass.frame(&mut app, vec![], true, false).expect("editing");
assert_eq!(ime_text(&empty), ("", 0, 0), "an available-empty document is not unavailable");
let text = "a".repeat(5000);
let (mut app, _) = editor(&text, 0, 5000);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let wide = pass.frame(&mut app, vec![], true, false).expect("editing");
assert!(matches!(wide.surrounding_text, egui::output::IMESurroundingText::Unavailable), "a selection wider than the limit is unavailable");
assert_eq!(wide.owner, ime_target(&app).expect("an edit target").owner, "identity survives unavailable text");
}
#[test]
fn an_unrelated_undo_keeps_a_wide_selection_composition() {
// A selection wider than the IME limit reports unavailable surrounding text, whose
// receipt identity is a revision. That revision must follow the editor, not the whole
// project: undoing an unrelated opacity change used to look like an editor mutation and
// cancel an active composition.
let text = "a".repeat(5000);
let (mut app, lid) = editor(&text, 0, 5000);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let first = pass.frame(&mut app, vec![], true, false).expect("editing");
assert!(matches!(first.surrounding_text, egui::output::IMESurroundingText::Unavailable), "the wide selection is unavailable");
let composing = pass
.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })], true, false)
.expect("still editing");
assert_eq!(composing.context_generation, first.context_generation, "the pre-edit keeps the context");
let owner = ime_owner(&app).expect("an editor owner");
assert!(matches!(
pass.ctx.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(pass.viewport))),
Some(ImeComposition::Active(active)) if active == owner
));
app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/opacity", "value": 50})).unwrap();
app.session.execute("edit.undo", json!({})).unwrap();
let after = pass.frame(&mut app, vec![], true, false).expect("still editing");
assert_eq!(after.context_generation, first.context_generation, "an unrelated undo does not start a new context");
assert!(
matches!(
pass.ctx.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(pass.viewport))),
Some(ImeComposition::Active(active)) if active == owner
),
"the wide-selection composition survives an unrelated undo"
);
assert!(read_reset(&pass.ctx).is_none(), "no IME reset was requested");
// The composition is still the live one: its commit is accepted and earns the
// input-method cause rather than being poisoned as leftover application work.
let commit = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("x".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), "x", "the preserved composition commits into the wide selection");
assert_eq!(commit.change_cause, egui::ImeChangeCause::InputMethod);
}
#[test]
fn a_failed_batch_with_a_transient_edit_does_not_revive_a_wide_selection_composition() {
// The final document equals the pre-batch one, but the intermediate insert/delete was a
// real document change. Its IME revision must stay the high-water mark; otherwise the
// active wide-selection composition would look current and accept its old commit.
let text = "a".repeat(5000);
let (mut app, lid) = editor(&text, 0, 5000);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![], true, false).expect("editing");
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })], true, false)
.expect("still editing");
let owner = ime_owner(&app).expect("an editor owner");
assert!(matches!(
pass.ctx.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(pass.viewport))),
Some(ImeComposition::Active(active)) if active == owner
));
let r = app.session.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "text.insert", "params": {"text": "x", "range": [5000, 5000]}},
{"command": "text.delete", "params": {}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_eq!(doc_text(&app, lid), text, "the batch restored the final document");
pass.frame(&mut app, vec![], true, false).expect("still editing");
assert!(read_reset(&pass.ctx).is_some(), "the advanced revision retires the old IME context");
assert!(
matches!(pass.ctx.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(pass.viewport))), Some(ImeComposition::Stale)),
"the failed batch's transient edit leaves the old composition stale"
);
let stale = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("z".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), text, "the stale composition cannot commit into the restored document");
assert_ne!(stale.change_cause, egui::ImeChangeCause::InputMethod, "a rejected commit earns no IME receipt");
}
#[test]
fn a_wide_content_change_invalidates_the_active_composition() {
let text = "a".repeat(5000);
let (mut app, lid) = editor(&text, 0, 5000);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![], true, false).expect("editing");
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })], true, false)
.expect("still editing");
let replaced = "b".repeat(5000);
app.session.execute("layer.setText", json!({"layer": lid.0, "text": replaced})).unwrap();
let after = pass.frame(&mut app, vec![], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), replaced);
assert!(after.should_interrupt_composition, "replacing the wide document starts a new IME context");
assert!(
matches!(pass.ctx.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(pass.viewport))), Some(ImeComposition::Stale)),
"the old composition is stale after the content change"
);
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("z".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), replaced, "a delayed commit from the old document is rejected");
}
#[test]
fn a_wide_selection_change_invalidates_the_active_composition() {
let text = "a".repeat(5000);
let (mut app, lid) = editor(&text, 0, 5000);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![], true, false).expect("editing");
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })], true, false)
.expect("still editing");
// Still wider than the IME window limit, so this exercises the unavailable-text
// selection identity rather than merely making the surrounding text available.
app.session.execute("text.setSelection", json!({"start": 0, "end": 4500})).unwrap();
pass.frame(&mut app, vec![], true, false).expect("still editing");
assert!(read_reset(&pass.ctx).is_some(), "an application selection change retires the composition");
assert!(
matches!(pass.ctx.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(pass.viewport))), Some(ImeComposition::Stale)),
"the old composition cannot follow the selection"
);
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("z".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), text, "the stale composition cannot mutate the wide selection");
}
#[test]
fn replaced_edit_session_and_hidden_passes_start_a_new_context() {
let (mut app, _) = editor("text", 4, 4);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let before = pass.frame(&mut app, vec![], true, false).expect("editing");
if let Some(edit) = app.session.state.text_edit.as_mut() {
edit.session = edit.session.wrapping_add(1);
}
let replaced = pass.frame(&mut app, vec![], true, false).expect("editing");
assert!(replaced.context_generation > before.context_generation, "a recreated target does not inherit the old context");
assert_eq!(replaced.change_cause, egui::ImeChangeCause::Other);
assert!(replaced.should_interrupt_composition, "a new replacement epoch cancels the platform composition");
let (mut app, _) = editor("abc", 3, 3);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let visible = pass.frame(&mut app, vec![], true, false).expect("editing");
assert!(pass.frame(&mut app, vec![], false, false).is_none(), "a hidden editor reports no IME owner");
let back = pass.frame(&mut app, vec![], true, false).expect("editing again");
assert!(back.context_generation > visible.context_generation, "reappearing starts exactly one new context");
}
#[test]
fn preedit_from_a_replaced_target_cannot_commit_into_the_new_session() {
let (mut app, first) = editor("A", 1, 1);
let second = app.session.execute("layer.newText", json!({"text": "B"})).unwrap()["layer"].as_u64().unwrap();
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "か".into(), active_range_chars: None })], true, false)
.expect("editing the first layer");
app.session.execute("text.edit", json!({"layer": second, "caret": 1})).unwrap();
let switched = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into()))], true, false).expect("editing the second layer");
assert_eq!(doc_text(&app, LayerId(second)), "B", "a delayed commit from the old composition is ignored");
assert_eq!(doc_text(&app, first), "A", "the old layer is not changed either");
assert_eq!(switched.owner, ime_target(&app).expect("second target").owner);
assert!(switched.should_interrupt_composition, "the target switch interrupts the old composition");
}
#[test]
fn hidden_pass_clears_this_viewports_preedit_preview() {
let (mut app, _) = editor("ab", 2, 2);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })], true, false).expect("editing");
assert!(pass.ctx.data(|d| d.get_temp::<Preedit>(ime_preedit_id(pass.viewport))).is_some());
assert!(pass.frame(&mut app, vec![], false, false).is_none());
assert!(pass.ctx.data(|d| d.get_temp::<Preedit>(ime_preedit_id(pass.viewport))).is_none(), "a pass without the overlay clears this viewport's preview");
}
#[test]
fn a_discarded_pass_keeps_the_frame_cause_and_the_context() {
let (mut app, lid) = editor("ab", 2, 2);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let first = pass.frame(&mut app, vec![], true, false).expect("editing");
let committed = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into()))], true, true).expect("the final pass of the frame");
assert_eq!(doc_text(&app, lid), "ab日", "the discarded pass did not replay the event");
assert_eq!(committed.change_cause, egui::ImeChangeCause::InputMethod, "an unchanged later pass keeps the frame's cause");
assert_eq!(committed.context_generation, first.context_generation, "both passes are one context");
}
#[test]
fn two_viewports_keep_independent_ime_contexts() {
let (mut app, _) = editor("ab", 2, 2);
let mut first = ImePass::new(egui::ViewportId::ROOT);
let mut second = ImePass::new(egui::ViewportId::from_hash_of("ime-test-second-viewport"));
let one = first.frame(&mut app, vec![], true, false).expect("editing in the first viewport");
let two = second.frame(&mut app, vec![], true, false).expect("editing in the second viewport");
assert_ne!(one.context_generation, two.context_generation, "each viewport has its own generation");
let again = first.frame(&mut app, vec![], true, false).expect("editing in the first viewport");
assert_eq!(again.context_generation, one.context_generation, "the second viewport did not disturb the first");
}
/// Draw one real `EffectcraftApp::draw` pass, so `draw`'s all-exit finalizer runs.
fn draw_app_pass(app: &mut EffectcraftApp, ctx: &egui::Context, frame: &eframe::Frame, events: Vec<egui::Event>) -> egui::FullOutput {
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| app.draw(ui, frame));
out.textures_delta.clear();
out
}
/// One headless pass with the custom viewer and a real stock `TextEdit` side by side.
///
/// The first call focuses the custom editor before `keyboard` (the outside press arrives
/// there). The second call leaves the stock field's focus alone, so the stock descriptor
/// is published by the same `output.ime` writer that an unrelated field would use.
fn stock_pass(
pass: &mut ImePass,
app: &mut EffectcraftApp,
events: Vec<egui::Event>,
stock: &mut String,
custom_focus: bool,
) -> Option<egui::output::IMEOutput> {
let ctx = pass.ctx.clone();
let viewport = pass.viewport;
let custom = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(160.0, 90.0));
let stock_rect = egui::Rect::from_min_size(egui::pos2(180.0, 30.0), egui::vec2(120.0, 24.0));
let stock_id = egui::Id::unique("effectcraft-ime-test-stock");
let mut viewports = egui::ViewportIdMap::default();
viewports.insert(viewport, egui::ViewportInfo::default());
let raw = egui::RawInput { viewport_id: viewport, viewports, screen_rect: Some(pass.rect), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
begin_ui(ui.ctx());
if custom_focus {
ui.interact(custom, focus_id(), egui::Sense::focusable_noninteractive());
ui.memory_mut(|m| m.request_focus(focus_id()));
sync_ime_target(ui, app);
keyboard(app, ui, custom);
} else {
// The stock field holds focus after the outside press; keep that ownership while
// the second pass publishes the actual stock descriptor.
ui.memory_mut(|m| m.request_focus(stock_id));
}
let _ = ui.put(stock_rect, egui::TextEdit::singleline(stock).id(stock_id));
finish_ime(app, ui);
});
out.textures_delta.clear();
out.platform_output.ime
}
#[test]
fn outside_pointer_to_stock_allocates_no_new_request_and_defers_the_replacement() {
let (mut app, lid) = editor("abcd", 4, 4);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let preview = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "é".into(), active_range_chars: None });
pass.frame(&mut app, vec![preview()], true, false).expect("the custom editor starts a composition");
app.session.execute("text.setSelection", json!({"caret": 1})).unwrap();
let first = pass.frame(&mut app, vec![], true, false).expect("the caret change publishes a request").reset_request.expect("the real request ID");
// Complete the first interruption, then start a fresh composition with no waiting guard.
pass.frame(&mut app, vec![egui::Event::ImeResetCompleted { request_id: first }], true, false);
let active = pass.frame(&mut app, vec![preview()], true, false).expect("a fresh composition");
assert!(active.reset_request.is_none(), "a fresh composition does not wait on an old request");
assert_eq!(doc_text(&app, lid), "abcd");
// The press outside the viewer also gives the stock field focus. It must not allocate a
// new request: core would attach that request to the stock descriptor next pass.
let outside = egui::pos2(240.0, 42.0);
let mut stock = String::new();
stock_pass(
&mut pass,
&mut app,
vec![
egui::Event::PointerMoved(outside),
egui::Event::PointerButton { pos: outside, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE },
],
&mut stock,
true,
);
let guard = pass.ctx.data(|d| d.get_temp::<ResetGuard>(reset_id(pass.viewport))).expect("the custom guard is retired locally");
assert!(guard.suspended && guard.request.is_none(), "the outside press retires the old request without allocating another");
let stock_ime = stock_pass(&mut pass, &mut app, vec![], &mut stock, false).expect("the actual stock field owns IME");
assert_ne!(stock_ime.owner, ime_target(&app).expect("the custom target is still live").owner);
assert!(stock_ime.reset_request.is_none(), "a new outside-press request would surface on the stock output");
assert_eq!(doc_text(&app, lid), "abcd");
// Returning to a confirmed eligible custom output allocates the one replacement.
let replacement = pass.frame(&mut app, vec![], true, false).expect("the custom editor owns IME again").reset_request.expect("the deferred replacement");
assert_ne!(first, replacement);
assert!(pass.frame(&mut app, vec![], true, false).expect("still editing").reset_request.is_none(), "the replacement is one-shot");
}
#[test]
fn fonts_and_device_loss_all_exit_finalizers_suspend_without_allocating() {
let (mut app, _lid) = editor("abcd", 1, 1);
app.ui.start_screen = false;
app.ui.tool = Tool::Type;
app.ui.focused = PanelKind::Composition;
app.show_panel(PanelKind::Composition);
let ctx = egui::Context::default();
install_hooks(&ctx);
let frame = eframe::Frame::_new_kittest();
let preview = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "é".into(), active_range_chars: None });
// The first pass installs fonts and the next draws the real viewer.
for _ in 0..3 {
draw_app_pass(&mut app, &ctx, &frame, vec![]);
}
draw_app_pass(&mut app, &ctx, &frame, vec![preview()]);
app.session.execute("text.setSelection", json!({"caret": 2})).unwrap();
let first = draw_app_pass(&mut app, &ctx, &frame, vec![])
.platform_output
.ime
.expect("the normal pass publishes the custom descriptor")
.reset_request
.expect("one request for the caret interruption");
// A real font-install pass draws nothing, but `draw` still reaches the finalizer.
app.styled = false;
let fonts = draw_app_pass(&mut app, &ctx, &frame, vec![]);
assert!(!app.is_ready(), "the font-install pass cannot draw");
assert!(fonts.platform_output.ime.is_none(), "no custom descriptor is published");
let guard = ctx.data(|d| d.get_temp::<ResetGuard>(reset_id(egui::ViewportId::ROOT))).expect("the waiting guard");
assert!(guard.suspended && guard.request.is_none(), "the all-exit finalizer retired it without allocating");
let second = draw_app_pass(&mut app, &ctx, &frame, vec![])
.platform_output
.ime
.expect("returning to a drawing pass publishes the custom descriptor")
.reset_request
.expect("the deferred request");
assert_ne!(first, second);
// The device-loss early return is the other all-exit path. Preset the close bookkeeping
// so this test does not autosave; the pass must still retire the waiting guard.
app.gpu_failure_applied = Some(true);
app.device_lost_close = Some(true);
let lost = draw_app_pass(&mut app, &ctx, &frame, vec![]);
assert!(lost.platform_output.ime.is_none(), "a lost renderer publishes no descriptor");
let guard = ctx.data(|d| d.get_temp::<ResetGuard>(reset_id(egui::ViewportId::ROOT))).expect("the waiting guard");
assert!(guard.suspended && guard.request.is_none(), "the device-loss finalizer retired it without allocating");
app.gpu_failure_applied = None;
let third = draw_app_pass(&mut app, &ctx, &frame, vec![])
.platform_output
.ime
.expect("a drawing pass publishes the custom descriptor again")
.reset_request
.expect("the deferred request is one-shot");
assert_ne!(third, second);
}
#[test]
fn ime_delete_without_a_session_or_with_an_empty_range_does_nothing() {
let (mut app, lid) = editor("abc", 1, 1);
feed(&mut app, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 0, after_bytes: 0 })]);
assert_eq!(doc_text(&app, lid), "abc");
app.session.state.text_edit = None;
feed(&mut app, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 2, after_bytes: 0 })]);
assert_eq!(doc_text(&app, lid), "abc");
}
}
mod tests;
@@ -0,0 +1,820 @@
//! Incoming IME edits on the custom text editor (the Type tool's own session, not a stock
//! `TextEdit`). Winit reports the bytes around the selection *before* the commit it belongs
//! to, so the deletion and the insert must share one undo step.
use super::*;
use effectcraft_engine::Session;
use effectcraft_engine::commands::text_edit::TextEdit;
use serde_json::json;
fn editor(text: &str, anchor: usize, caret: usize) -> (EffectcraftApp, LayerId) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "IME", "width": 160, "height": 90, "duration": 1})).unwrap();
let layer = s.execute("layer.newText", json!({"text": text})).unwrap()["layer"].as_u64().unwrap();
let lid = LayerId(layer);
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, ..Default::default() });
(EffectcraftApp::new(s), lid)
}
fn doc_text(app: &EffectcraftApp, lid: LayerId) -> String {
layer_doc(&app.session, lid).map(|d| d.text).unwrap_or_default()
}
fn feed(app: &mut EffectcraftApp, events: Vec<egui::Event>) {
let ctx = egui::Context::default();
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 180.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| keyboard(app, ui, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 180.0))));
out.textures_delta.clear();
}
#[test]
fn preedit_preview_is_bounded_on_a_character_boundary() {
let text = "é".repeat(MAX_IME_TEXT_BYTES);
let capped = bounded_preedit(text);
assert!(capped.len() <= MAX_IME_TEXT_BYTES);
assert!(capped.is_char_boundary(capped.len()));
assert_eq!(capped.len() % "é".len(), 0, "the cap never leaves half a character");
}
/// Ending the edit from another panel clears this viewport's pre-edit. The production path
/// used to resolve `viewport_id()` (a context read lock) inside `data_mut` (a write lock);
/// that nested context access deadlocked every pass that drew the viewer.
#[test]
fn ending_an_edit_from_another_panel_clears_the_preedit() {
let (mut app, lid) = editor("abc", 0, 0);
let session = app.session.state.text_edit.as_ref().map_or(1, |e| e.session);
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let viewport = ui.ctx().viewport_id();
let preedit = Preedit { owner: ImeOwner { layer: lid, session }, text: "x".into() };
ui.data_mut(|d| d.insert_temp(ime_preedit_id(viewport), preedit));
panel_clicked(&mut app, ui.ctx(), PanelKind::Timeline);
let viewport = ui.ctx().viewport_id();
assert!(ui.data(|d| d.get_temp::<Preedit>(ime_preedit_id(viewport))).is_none(), "the ended edit leaves no preview behind");
});
out.textures_delta.clear();
assert!(app.session.state.text_edit.is_none(), "a click in the Timeline ends the text edit");
}
#[test]
fn ime_delete_surrounding_bytes_and_the_commit_are_one_undo_step() {
// "café😀": the last character is four bytes, so a byte-count deletion that treated bytes
// as characters would corrupt the document.
let (mut app, lid) = editor("café😀", 5, 5);
feed(
&mut app,
vec![
egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 4, after_bytes: 0 }),
egui::Event::Ime(egui::ImeEvent::Commit("X".into())),
],
);
assert_eq!(doc_text(&app, lid), "caféX", "the emoji is replaced by the committed text");
app.session.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc_text(&app, lid), "café😀", "the replacement is one undo step");
}
#[test]
fn ime_delete_surrounding_chars_keeps_the_selection_for_the_commit() {
// Stock egui deletes the characters around the selection, not the selection itself: the
// commit that follows replaces "bcd", it does not insert next to a deleted document.
let (mut app, lid) = editor("abcdef", 1, 4);
feed(&mut app, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 99 })]);
assert_eq!(doc_text(&app, lid), "bcd", "one character before and everything after go");
let sel = app.session.state.text_edit.clone().expect("editing continues");
assert_eq!(sel.range(), 0..3, "the selected text is still selected");
// A deletion-only batch (no commit) leaves "bcd", not an empty document.
app.session.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc_text(&app, lid), "abcdef", "clamped deletion is still one undo step");
// Delete surround + commit: the commit replaces the kept selection.
let (mut app, lid) = editor("abcdef", 1, 4);
feed(
&mut app,
vec![
egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 99 }),
egui::Event::Ime(egui::ImeEvent::Commit("XY".into())),
],
);
assert_eq!(doc_text(&app, lid), "XY", "the commit replaces the selection");
app.session.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc_text(&app, lid), "abcdef", "the deletion and its commit are one undo step");
}
/// One UI pass with the editor focused, events applied, and the overlay's observation.
struct ImePass {
ctx: egui::Context,
viewport: egui::ViewportId,
rect: egui::Rect,
}
impl ImePass {
fn new(viewport: egui::ViewportId) -> Self {
let ctx = egui::Context::default();
install_hooks(&ctx);
Self { ctx, viewport, rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 180.0)) }
}
/// Run one `Context` frame. `overlay` is false for a pass in which the viewer (and so
/// the edit overlay) never drew; `discard` forces a second pass of the same frame.
fn frame(&mut self, app: &mut EffectcraftApp, events: Vec<egui::Event>, overlay: bool, discard: bool) -> Option<egui::output::IMEOutput> {
let ctx = self.ctx.clone();
let rect = self.rect;
let viewport = self.viewport;
let mut viewports = egui::ViewportIdMap::default();
viewports.insert(self.viewport, egui::ViewportInfo::default());
let raw = egui::RawInput { viewport_id: self.viewport, viewports, screen_rect: Some(rect), events, ..Default::default() };
let mut asked = false;
let mut out = ctx.run_ui(raw, |ui| {
begin_ui(ui.ctx());
ui.interact(rect, focus_id(), egui::Sense::focusable_noninteractive());
ui.memory_mut(|m| m.request_focus(focus_id()));
sync_ime_target(ui, app);
keyboard(app, ui, rect);
if overlay {
let pass = ui.ctx().cumulative_pass_nr_for(viewport);
ui.data_mut(|d| d.insert_temp(ime_geometry_id(viewport), (pass, rect, rect)));
}
finish_ime(app, ui);
if discard && !asked {
asked = true;
ui.ctx().request_discard("ime pass test");
}
});
out.textures_delta.clear();
out.platform_output.ime
}
}
/// One native floating window holding the Timeline's group and then the Viewer's.
fn two_group_window() -> craft_ui::docking::Floating<PanelKind> {
let groups = vec![craft_ui::docking::Group::new(vec![PanelKind::Timeline], 0), craft_ui::docking::Group::new(vec![PanelKind::Viewer(1)], 0)];
craft_ui::docking::Floating::stack(groups, [0.0, 0.0, 320.0, 360.0]).expect("two groups make a window")
}
impl ImePass {
/// One pass of a native window with a body that edits no text and the Viewer's editor,
/// in the order given, publishing as `NativeIme` decides or, with `every`, after each
/// body. Returns the descriptor and the preedit the pass ended with.
fn window(
&mut self,
app: &mut EffectcraftApp,
window: &craft_ui::docking::Floating<PanelKind>,
events: Vec<egui::Event>,
viewer_first: bool,
every: bool,
) -> (Option<egui::output::IMEOutput>, Option<String>) {
let ctx = self.ctx.clone();
let (rect, viewport) = (self.rect, self.viewport);
let mut viewports = egui::ViewportIdMap::default();
viewports.insert(viewport, egui::ViewportInfo::default());
let raw = egui::RawInput { viewport_id: viewport, viewports, screen_rect: Some(rect), events, ..Default::default() };
let mut preedit = None;
let mut out = ctx.run_ui(raw, |ui| {
let mut ime = crate::dock_ui::NativeIme::new(std::slice::from_ref(window), &[]);
let order = if viewer_first { [true, false] } else { [false, true] };
for viewer in order {
if ime.begin_body(viewport) {
begin_ui(ui.ctx());
}
if viewer {
ui.interact(rect, focus_id(), egui::Sense::focusable_noninteractive());
ui.memory_mut(|m| m.request_focus(focus_id()));
sync_ime_target(ui, app);
keyboard(app, ui, rect);
let pass = ui.ctx().cumulative_pass_nr_for(viewport);
ui.data_mut(|d| d.insert_temp(ime_geometry_id(viewport), (pass, rect, rect)));
}
if ime.publishes(viewport, ime_recorded(ui.ctx())) || every {
finish_ime(app, ui);
}
}
assert!(ime.unpublished().is_empty(), "the window published");
preedit = read_preedit(ui).map(|p| p.text);
});
out.textures_delta.clear();
(out.platform_output.ime, preedit)
}
}
#[test]
fn native_window_with_a_group_before_the_viewer_keeps_the_composition() {
let window = two_group_window();
let viewport = craft_ui::docking::DockArea::<PanelKind>::window_viewport(crate::dock_ui::area_id(), window.id);
let preview = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "é".into(), active_range_chars: None });
for viewer_first in [false, true] {
let (mut app, _) = editor("abcd", 4, 4);
let mut pass = ImePass::new(viewport);
let (first, preedit) = pass.window(&mut app, &window, vec![preview()], viewer_first, false);
let first = first.expect("the editor publishes");
assert_eq!(preedit.as_deref(), Some("é"));
for _ in 0..2 {
let (ime, preedit) = pass.window(&mut app, &window, vec![], viewer_first, false);
let ime = ime.expect("the editor keeps publishing");
assert!(!ime.should_interrupt_composition, "viewer first: {viewer_first}");
assert_eq!((ime.owner, ime.context_generation), (first.owner, first.context_generation), "the same IME context");
assert_eq!(preedit.as_deref(), Some("é"), "the preedit survives the other body");
}
}
// Publishing after the body drawn before the Viewer clears the preedit and interrupts.
let (mut app, _) = editor("abcd", 4, 4);
let mut pass = ImePass::new(viewport);
pass.window(&mut app, &window, vec![preview()], false, true);
let (ime, preedit) = pass.window(&mut app, &window, vec![], false, true);
assert!(ime.expect("the editor publishes").should_interrupt_composition);
assert_eq!(preedit, None);
// A window that drew fewer bodies than its layout counts publishes after every body in
// its next pass.
let mut ime = crate::dock_ui::NativeIme::new(std::slice::from_ref(&window), &[]);
ime.begin_body(viewport);
assert!(!ime.publishes(viewport, false));
let unpublished = ime.unpublished();
assert_eq!(unpublished, vec![viewport]);
let mut ime = crate::dock_ui::NativeIme::new(std::slice::from_ref(&window), &unpublished);
ime.begin_body(viewport);
assert!(ime.publishes(viewport, false));
}
fn ime_text(ime: &egui::output::IMEOutput) -> (&str, usize, usize) {
let p = ime.surrounding_text.available().expect("committed surrounding text");
(p.text.as_str(), p.cursor, p.anchor)
}
#[test]
fn discarded_custom_request_then_home_rearms_the_identical_caret() {
let (mut app, lid) = editor("abcd", 4, 4);
app.ui.start_screen = false;
app.ui.tool = Tool::Type;
app.ui.focused = PanelKind::Composition;
let ctx = egui::Context::default();
install_hooks(&ctx);
let mut frame = eframe::Frame::_new_kittest();
let raw = || egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), ..Default::default() };
for _ in 0..3 {
let mut output = ctx.run_ui(raw(), |ui| {
eframe::App::logic(&mut app, ui.ctx(), &mut frame);
app.draw(ui, &frame);
});
output.textures_delta.clear();
}
let mut input = raw();
input.events.push(egui::Event::Ime(egui::ImeEvent::Preedit { text: "é".into(), active_range_chars: None }));
let mut output = ctx.run_ui(input, |ui| {
eframe::App::logic(&mut app, ui.ctx(), &mut frame);
app.draw(ui, &frame);
});
output.textures_delta.clear();
assert!(output.platform_output.ime.is_some());
app.session.execute("text.setSelection", json!({"caret": 1})).unwrap();
let selection = app.session.state.text_edit.clone();
let mut passes = 0;
let mut discarded = None;
let mut output = ctx.run_ui(raw(), |ui| {
passes += 1;
if passes > 1 {
app.ui.start_screen = true;
}
eframe::App::logic(&mut app, ui.ctx(), &mut frame);
app.draw(ui, &frame);
if passes == 1 {
assert!(ui.ctx().output(|output| output.ime.is_some()));
discarded = read_reset(ui.ctx()).and_then(|guard| guard.request);
ui.ctx().request_discard("custom IME descriptor discarded before Home");
}
});
output.textures_delta.clear();
assert!(passes >= 2);
let first = discarded.expect("the actual discarded custom pass requested a fence");
assert!(output.platform_output.ime.is_none());
assert!(read_reset(&ctx).unwrap().suspended);
assert!(read_reset(&ctx).unwrap().request.is_none());
assert_eq!(app.session.state.text_edit, selection, "Home did not move the model caret");
app.ui.start_screen = false;
let mut output = ctx.run_ui(raw(), |ui| {
eframe::App::logic(&mut app, ui.ctx(), &mut frame);
app.draw(ui, &frame);
});
output.textures_delta.clear();
let second = output.platform_output.ime.unwrap().reset_request.expect("same caret reactivation publishes a new real ID");
assert_ne!(first, second);
let mut output = ctx.run_ui(raw(), |ui| {
eframe::App::logic(&mut app, ui.ctx(), &mut frame);
app.draw(ui, &frame);
});
output.textures_delta.clear();
assert!(output.platform_output.ime.unwrap().reset_request.is_none());
let mut input = raw();
input.events.extend([
egui::Event::ImeResetCompleted { request_id: first },
egui::Event::Ime(egui::ImeEvent::Commit("X".into())),
egui::Event::ImeResetCompleted { request_id: second },
egui::Event::Ime(egui::ImeEvent::Preedit { text: "が".into(), active_range_chars: None }),
egui::Event::Ime(egui::ImeEvent::Commit("が".into())),
]);
let mut output = ctx.run_ui(input, |ui| {
eframe::App::logic(&mut app, ui.ctx(), &mut frame);
app.draw(ui, &frame);
});
output.textures_delta.clear();
assert_eq!(doc_text(&app, lid), "aがbcd");
}
#[test]
fn viewport_fences_and_duplicate_completion_keep_their_exact_lifetime() {
let (mut app, lid) = editor("abcd", 4, 4);
let mut root = ImePass::new(egui::ViewportId::ROOT);
let mut detached = ImePass::new(egui::ViewportId::from_hash_of("effect-ime-detached"));
let preview = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "é".into(), active_range_chars: None });
root.frame(&mut app, vec![preview()], true, false);
detached.frame(&mut app, vec![preview()], true, false);
app.session.execute("text.setSelection", json!({"caret": 1})).unwrap();
let first = root.frame(&mut app, vec![], true, false).unwrap().reset_request.unwrap();
let second = detached.frame(&mut app, vec![], true, false).unwrap().reset_request.unwrap();
assert_ne!(first, second);
detached.frame(&mut app, vec![egui::Event::ImeResetCompleted { request_id: first }, egui::Event::Ime(egui::ImeEvent::Commit("X".into()))], true, false);
assert_eq!(doc_text(&app, lid), "abcd");
let pending = detached.ctx.data(|data| data.get_temp::<ResetGuard>(reset_id(detached.viewport))).unwrap().request;
assert_eq!(pending, Some(second), "a root marker cannot release the detached viewport's full ID");
let output = detached
.frame(
&mut app,
vec![
egui::Event::ImeResetCompleted { request_id: second },
preview(),
egui::Event::ImeResetCompleted { request_id: second },
egui::Event::Ime(egui::ImeEvent::Commit("が".into())),
],
true,
false,
)
.unwrap();
assert_eq!(doc_text(&app, lid), "aがbcd");
assert!(output.reset_request.is_none(), "a duplicate control cannot reconstruct the cleared guard");
// An actual context-generation change at unchanged selection still supersedes a wait.
root.frame(&mut app, vec![], true, false);
let replaced = root.frame(&mut app, vec![], false, false);
assert!(replaced.is_none());
let replacement = root.frame(&mut app, vec![], true, false).unwrap().reset_request.unwrap();
assert_ne!(first, replacement);
}
#[test]
fn ime_descriptor_sends_committed_text_and_preserves_a_reversed_selection() {
let (mut app, _) = editor("café😀", 2, 5);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let first = pass.frame(&mut app, vec![], true, false).expect("the focused editor owns IME");
assert_eq!(first.owner, ime_target(&app).expect("an edit target").owner);
assert!(first.context_generation > 0);
assert_eq!(ime_text(&first), ("café😀", 9, 2), "UTF-8 bytes, with the caret's direction kept");
assert_eq!(first.change_cause, egui::ImeChangeCause::Other, "starting a context is application origin");
assert_eq!((first.rect, first.cursor_rect), (pass.rect, pass.rect), "the real caret geometry");
let again = pass.frame(&mut app, vec![], true, false).expect("still editing");
assert_eq!(again.context_generation, first.context_generation, "an unchanged pass keeps the context");
assert_eq!(again.surrounding_text, first.surrounding_text);
}
#[test]
fn accepted_ime_commit_is_input_method_and_ordinary_typing_is_other() {
let (mut app, lid) = editor("ab", 2, 2);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let first = pass.frame(&mut app, vec![], true, false).expect("editing");
let generation = first.context_generation;
let commit = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), "ab日");
assert_eq!(ime_text(&commit), ("ab日", 5, 5));
assert_eq!(commit.change_cause, egui::ImeChangeCause::InputMethod, "only the accepted commit earns it");
assert_eq!(commit.context_generation, generation, "an ordinary text change is not a new context");
let typed = pass.frame(&mut app, vec![egui::Event::Text("x".into())], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), "ab日x");
assert_eq!(typed.change_cause, egui::ImeChangeCause::Other, "typing is application work");
assert_eq!(typed.context_generation, generation);
}
#[test]
fn ime_delete_and_commit_chain_is_input_method_with_a_reversed_selection() {
let (mut app, lid) = editor("abcdef", 4, 1);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![], true, false).expect("editing");
let deleted = pass
.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 1 })], true, false)
.expect("still editing");
assert_eq!(doc_text(&app, lid), "bcdf", "one character goes before and one after the selection");
assert_eq!(ime_text(&deleted), ("bcdf", 0, 3), "the selected text and its reversed direction stay");
assert_eq!(deleted.change_cause, egui::ImeChangeCause::InputMethod);
let committed = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("X".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), "Xf", "the commit replaces the kept selection");
assert_eq!(committed.change_cause, egui::ImeChangeCause::InputMethod, "the delete and commit are one accepted chain");
app.session.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc_text(&app, lid), "abcdef", "the replacement is one undo step");
}
#[test]
fn an_application_edit_in_the_same_frame_dominates_the_ime_cause() {
// Application edit before the IME commit.
let (mut app, lid) = editor("ab", 2, 2);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![], true, false).expect("editing");
let mixed = pass
.frame(&mut app, vec![egui::Event::Text("x".into()), egui::Event::Ime(egui::ImeEvent::Commit("日".into()))], true, false)
.expect("still editing");
assert_eq!(doc_text(&app, lid), "abx日");
assert_eq!(mixed.change_cause, egui::ImeChangeCause::Other, "the earlier application edit dominates");
// Application edit after the IME commit.
let (mut app, lid) = editor("ab", 2, 2);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![], true, false).expect("editing");
let mixed = pass
.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into())), egui::Event::Text("x".into())], true, false)
.expect("still editing");
assert_eq!(doc_text(&app, lid), "ab日x");
assert_eq!(mixed.change_cause, egui::ImeChangeCause::Other, "the later application edit dominates");
}
#[test]
fn preedit_is_excluded_from_the_committed_snapshot_and_keeps_the_context() {
let (mut app, lid) = editor("ab", 2, 2);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let first = pass.frame(&mut app, vec![], true, false).expect("editing");
let preview = pass
.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })], true, false)
.expect("still editing");
assert_eq!(doc_text(&app, lid), "ab", "the pre-edit never enters the document");
assert_eq!(ime_text(&preview), ("ab", 2, 2), "only committed text is sent");
assert_eq!(preview.context_generation, first.context_generation, "pre-edit updates keep the composition");
let commit = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("に".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), "abに");
assert_eq!(commit.change_cause, egui::ImeChangeCause::InputMethod, "clearing the pre-edit and committing is IME work");
assert_eq!(commit.context_generation, first.context_generation);
}
#[test]
fn empty_and_unavailable_surrounding_text_are_distinct() {
let (mut app, _) = editor("", 0, 0);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let empty = pass.frame(&mut app, vec![], true, false).expect("editing");
assert_eq!(ime_text(&empty), ("", 0, 0), "an available-empty document is not unavailable");
let text = "a".repeat(5000);
let (mut app, _) = editor(&text, 0, 5000);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let wide = pass.frame(&mut app, vec![], true, false).expect("editing");
assert!(matches!(wide.surrounding_text, egui::output::IMESurroundingText::Unavailable), "a selection wider than the limit is unavailable");
assert_eq!(wide.owner, ime_target(&app).expect("an edit target").owner, "identity survives unavailable text");
}
#[test]
fn an_unrelated_undo_keeps_a_wide_selection_composition() {
// A selection wider than the IME limit reports unavailable surrounding text, whose
// receipt identity is a revision. That revision must follow the editor, not the whole
// project: undoing an unrelated opacity change used to look like an editor mutation and
// cancel an active composition.
let text = "a".repeat(5000);
let (mut app, lid) = editor(&text, 0, 5000);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let first = pass.frame(&mut app, vec![], true, false).expect("editing");
assert!(matches!(first.surrounding_text, egui::output::IMESurroundingText::Unavailable), "the wide selection is unavailable");
let composing = pass
.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })], true, false)
.expect("still editing");
assert_eq!(composing.context_generation, first.context_generation, "the pre-edit keeps the context");
let owner = ime_owner(&app).expect("an editor owner");
assert!(matches!(
pass.ctx.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(pass.viewport))),
Some(ImeComposition::Active(active)) if active == owner
));
app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/opacity", "value": 50})).unwrap();
app.session.execute("edit.undo", json!({})).unwrap();
let after = pass.frame(&mut app, vec![], true, false).expect("still editing");
assert_eq!(after.context_generation, first.context_generation, "an unrelated undo does not start a new context");
assert!(
matches!(
pass.ctx.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(pass.viewport))),
Some(ImeComposition::Active(active)) if active == owner
),
"the wide-selection composition survives an unrelated undo"
);
assert!(read_reset(&pass.ctx).is_none(), "no IME reset was requested");
// The composition is still the live one: its commit is accepted and earns the
// input-method cause rather than being poisoned as leftover application work.
let commit = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("x".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), "x", "the preserved composition commits into the wide selection");
assert_eq!(commit.change_cause, egui::ImeChangeCause::InputMethod);
}
#[test]
fn a_failed_batch_with_a_transient_edit_does_not_revive_a_wide_selection_composition() {
// The final document equals the pre-batch one, but the intermediate insert/delete was a
// real document change. Its IME revision must stay the high-water mark; otherwise the
// active wide-selection composition would look current and accept its old commit.
let text = "a".repeat(5000);
let (mut app, lid) = editor(&text, 0, 5000);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![], true, false).expect("editing");
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })], true, false)
.expect("still editing");
let owner = ime_owner(&app).expect("an editor owner");
assert!(matches!(
pass.ctx.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(pass.viewport))),
Some(ImeComposition::Active(active)) if active == owner
));
let r = app.session.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "text.insert", "params": {"text": "x", "range": [5000, 5000]}},
{"command": "text.delete", "params": {}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert_eq!(doc_text(&app, lid), text, "the batch restored the final document");
pass.frame(&mut app, vec![], true, false).expect("still editing");
assert!(read_reset(&pass.ctx).is_some(), "the advanced revision retires the old IME context");
assert!(
matches!(pass.ctx.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(pass.viewport))), Some(ImeComposition::Stale)),
"the failed batch's transient edit leaves the old composition stale"
);
let stale = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("z".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), text, "the stale composition cannot commit into the restored document");
assert_ne!(stale.change_cause, egui::ImeChangeCause::InputMethod, "a rejected commit earns no IME receipt");
}
#[test]
fn a_wide_content_change_invalidates_the_active_composition() {
let text = "a".repeat(5000);
let (mut app, lid) = editor(&text, 0, 5000);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![], true, false).expect("editing");
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })], true, false)
.expect("still editing");
let replaced = "b".repeat(5000);
app.session.execute("layer.setText", json!({"layer": lid.0, "text": replaced})).unwrap();
let after = pass.frame(&mut app, vec![], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), replaced);
assert!(after.should_interrupt_composition, "replacing the wide document starts a new IME context");
assert!(
matches!(pass.ctx.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(pass.viewport))), Some(ImeComposition::Stale)),
"the old composition is stale after the content change"
);
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("z".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), replaced, "a delayed commit from the old document is rejected");
}
#[test]
fn a_wide_selection_change_invalidates_the_active_composition() {
let text = "a".repeat(5000);
let (mut app, lid) = editor(&text, 0, 5000);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![], true, false).expect("editing");
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })], true, false)
.expect("still editing");
// Still wider than the IME window limit, so this exercises the unavailable-text
// selection identity rather than merely making the surrounding text available.
app.session.execute("text.setSelection", json!({"start": 0, "end": 4500})).unwrap();
pass.frame(&mut app, vec![], true, false).expect("still editing");
assert!(read_reset(&pass.ctx).is_some(), "an application selection change retires the composition");
assert!(
matches!(pass.ctx.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(pass.viewport))), Some(ImeComposition::Stale)),
"the old composition cannot follow the selection"
);
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("z".into()))], true, false).expect("still editing");
assert_eq!(doc_text(&app, lid), text, "the stale composition cannot mutate the wide selection");
}
#[test]
fn replaced_edit_session_and_hidden_passes_start_a_new_context() {
let (mut app, _) = editor("text", 4, 4);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let before = pass.frame(&mut app, vec![], true, false).expect("editing");
if let Some(edit) = app.session.state.text_edit.as_mut() {
edit.session = edit.session.wrapping_add(1);
}
let replaced = pass.frame(&mut app, vec![], true, false).expect("editing");
assert!(replaced.context_generation > before.context_generation, "a recreated target does not inherit the old context");
assert_eq!(replaced.change_cause, egui::ImeChangeCause::Other);
assert!(replaced.should_interrupt_composition, "a new replacement epoch cancels the platform composition");
let (mut app, _) = editor("abc", 3, 3);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let visible = pass.frame(&mut app, vec![], true, false).expect("editing");
assert!(pass.frame(&mut app, vec![], false, false).is_none(), "a hidden editor reports no IME owner");
let back = pass.frame(&mut app, vec![], true, false).expect("editing again");
assert!(back.context_generation > visible.context_generation, "reappearing starts exactly one new context");
}
#[test]
fn preedit_from_a_replaced_target_cannot_commit_into_the_new_session() {
let (mut app, first) = editor("A", 1, 1);
let second = app.session.execute("layer.newText", json!({"text": "B"})).unwrap()["layer"].as_u64().unwrap();
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "か".into(), active_range_chars: None })], true, false)
.expect("editing the first layer");
app.session.execute("text.edit", json!({"layer": second, "caret": 1})).unwrap();
let switched = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into()))], true, false).expect("editing the second layer");
assert_eq!(doc_text(&app, LayerId(second)), "B", "a delayed commit from the old composition is ignored");
assert_eq!(doc_text(&app, first), "A", "the old layer is not changed either");
assert_eq!(switched.owner, ime_target(&app).expect("second target").owner);
assert!(switched.should_interrupt_composition, "the target switch interrupts the old composition");
}
#[test]
fn hidden_pass_clears_this_viewports_preedit_preview() {
let (mut app, _) = editor("ab", 2, 2);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })], true, false).expect("editing");
assert!(pass.ctx.data(|d| d.get_temp::<Preedit>(ime_preedit_id(pass.viewport))).is_some());
assert!(pass.frame(&mut app, vec![], false, false).is_none());
assert!(pass.ctx.data(|d| d.get_temp::<Preedit>(ime_preedit_id(pass.viewport))).is_none(), "a pass without the overlay clears this viewport's preview");
}
#[test]
fn a_discarded_pass_keeps_the_frame_cause_and_the_context() {
let (mut app, lid) = editor("ab", 2, 2);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let first = pass.frame(&mut app, vec![], true, false).expect("editing");
let committed = pass.frame(&mut app, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into()))], true, true).expect("the final pass of the frame");
assert_eq!(doc_text(&app, lid), "ab日", "the discarded pass did not replay the event");
assert_eq!(committed.change_cause, egui::ImeChangeCause::InputMethod, "an unchanged later pass keeps the frame's cause");
assert_eq!(committed.context_generation, first.context_generation, "both passes are one context");
}
#[test]
fn two_viewports_keep_independent_ime_contexts() {
let (mut app, _) = editor("ab", 2, 2);
let mut first = ImePass::new(egui::ViewportId::ROOT);
let mut second = ImePass::new(egui::ViewportId::from_hash_of("ime-test-second-viewport"));
let one = first.frame(&mut app, vec![], true, false).expect("editing in the first viewport");
let two = second.frame(&mut app, vec![], true, false).expect("editing in the second viewport");
assert_ne!(one.context_generation, two.context_generation, "each viewport has its own generation");
let again = first.frame(&mut app, vec![], true, false).expect("editing in the first viewport");
assert_eq!(again.context_generation, one.context_generation, "the second viewport did not disturb the first");
}
/// Draw one real `EffectcraftApp::draw` pass, so `draw`'s all-exit finalizer runs.
fn draw_app_pass(app: &mut EffectcraftApp, ctx: &egui::Context, frame: &eframe::Frame, events: Vec<egui::Event>) -> egui::FullOutput {
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| app.draw(ui, frame));
out.textures_delta.clear();
out
}
/// One headless pass with the custom viewer and a real stock `TextEdit` side by side.
///
/// The first call focuses the custom editor before `keyboard` (the outside press arrives
/// there). The second call leaves the stock field's focus alone, so the stock descriptor
/// is published by the same `output.ime` writer that an unrelated field would use.
fn stock_pass(
pass: &mut ImePass,
app: &mut EffectcraftApp,
events: Vec<egui::Event>,
stock: &mut String,
custom_focus: bool,
) -> Option<egui::output::IMEOutput> {
let ctx = pass.ctx.clone();
let viewport = pass.viewport;
let custom = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(160.0, 90.0));
let stock_rect = egui::Rect::from_min_size(egui::pos2(180.0, 30.0), egui::vec2(120.0, 24.0));
let stock_id = egui::Id::unique("effectcraft-ime-test-stock");
let mut viewports = egui::ViewportIdMap::default();
viewports.insert(viewport, egui::ViewportInfo::default());
let raw = egui::RawInput { viewport_id: viewport, viewports, screen_rect: Some(pass.rect), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
begin_ui(ui.ctx());
if custom_focus {
ui.interact(custom, focus_id(), egui::Sense::focusable_noninteractive());
ui.memory_mut(|m| m.request_focus(focus_id()));
sync_ime_target(ui, app);
keyboard(app, ui, custom);
} else {
// The stock field holds focus after the outside press; keep that ownership while
// the second pass publishes the actual stock descriptor.
ui.memory_mut(|m| m.request_focus(stock_id));
}
let _ = ui.put(stock_rect, egui::TextEdit::singleline(stock).id(stock_id));
finish_ime(app, ui);
});
out.textures_delta.clear();
out.platform_output.ime
}
#[test]
fn outside_pointer_to_stock_allocates_no_new_request_and_defers_the_replacement() {
let (mut app, lid) = editor("abcd", 4, 4);
let mut pass = ImePass::new(egui::ViewportId::ROOT);
let preview = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "é".into(), active_range_chars: None });
pass.frame(&mut app, vec![preview()], true, false).expect("the custom editor starts a composition");
app.session.execute("text.setSelection", json!({"caret": 1})).unwrap();
let first = pass.frame(&mut app, vec![], true, false).expect("the caret change publishes a request").reset_request.expect("the real request ID");
// Complete the first interruption, then start a fresh composition with no waiting guard.
pass.frame(&mut app, vec![egui::Event::ImeResetCompleted { request_id: first }], true, false);
let active = pass.frame(&mut app, vec![preview()], true, false).expect("a fresh composition");
assert!(active.reset_request.is_none(), "a fresh composition does not wait on an old request");
assert_eq!(doc_text(&app, lid), "abcd");
// The press outside the viewer also gives the stock field focus. It must not allocate a
// new request: core would attach that request to the stock descriptor next pass.
let outside = egui::pos2(240.0, 42.0);
let mut stock = String::new();
stock_pass(
&mut pass,
&mut app,
vec![
egui::Event::PointerMoved(outside),
egui::Event::PointerButton { pos: outside, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE },
],
&mut stock,
true,
);
let guard = pass.ctx.data(|d| d.get_temp::<ResetGuard>(reset_id(pass.viewport))).expect("the custom guard is retired locally");
assert!(guard.suspended && guard.request.is_none(), "the outside press retires the old request without allocating another");
let stock_ime = stock_pass(&mut pass, &mut app, vec![], &mut stock, false).expect("the actual stock field owns IME");
assert_ne!(stock_ime.owner, ime_target(&app).expect("the custom target is still live").owner);
assert!(stock_ime.reset_request.is_none(), "a new outside-press request would surface on the stock output");
assert_eq!(doc_text(&app, lid), "abcd");
// Returning to a confirmed eligible custom output allocates the one replacement.
let replacement = pass.frame(&mut app, vec![], true, false).expect("the custom editor owns IME again").reset_request.expect("the deferred replacement");
assert_ne!(first, replacement);
assert!(pass.frame(&mut app, vec![], true, false).expect("still editing").reset_request.is_none(), "the replacement is one-shot");
}
#[test]
fn fonts_and_device_loss_all_exit_finalizers_suspend_without_allocating() {
let (mut app, _lid) = editor("abcd", 1, 1);
app.ui.start_screen = false;
app.ui.tool = Tool::Type;
app.ui.focused = PanelKind::Composition;
app.show_panel(PanelKind::Composition);
let ctx = egui::Context::default();
install_hooks(&ctx);
let frame = eframe::Frame::_new_kittest();
let preview = || egui::Event::Ime(egui::ImeEvent::Preedit { text: "é".into(), active_range_chars: None });
// The first pass installs fonts and the next draws the real viewer.
for _ in 0..3 {
draw_app_pass(&mut app, &ctx, &frame, vec![]);
}
draw_app_pass(&mut app, &ctx, &frame, vec![preview()]);
app.session.execute("text.setSelection", json!({"caret": 2})).unwrap();
let first = draw_app_pass(&mut app, &ctx, &frame, vec![])
.platform_output
.ime
.expect("the normal pass publishes the custom descriptor")
.reset_request
.expect("one request for the caret interruption");
// A real font-install pass draws nothing, but `draw` still reaches the finalizer.
app.styled = false;
let fonts = draw_app_pass(&mut app, &ctx, &frame, vec![]);
assert!(!app.is_ready(), "the font-install pass cannot draw");
assert!(fonts.platform_output.ime.is_none(), "no custom descriptor is published");
let guard = ctx.data(|d| d.get_temp::<ResetGuard>(reset_id(egui::ViewportId::ROOT))).expect("the waiting guard");
assert!(guard.suspended && guard.request.is_none(), "the all-exit finalizer retired it without allocating");
let second = draw_app_pass(&mut app, &ctx, &frame, vec![])
.platform_output
.ime
.expect("returning to a drawing pass publishes the custom descriptor")
.reset_request
.expect("the deferred request");
assert_ne!(first, second);
// The device-loss early return is the other all-exit path. Preset the close bookkeeping
// so this test does not autosave; the pass must still retire the waiting guard.
app.gpu_failure_applied = Some(true);
app.device_lost_close = Some(true);
let lost = draw_app_pass(&mut app, &ctx, &frame, vec![]);
assert!(lost.platform_output.ime.is_none(), "a lost renderer publishes no descriptor");
let guard = ctx.data(|d| d.get_temp::<ResetGuard>(reset_id(egui::ViewportId::ROOT))).expect("the waiting guard");
assert!(guard.suspended && guard.request.is_none(), "the device-loss finalizer retired it without allocating");
app.gpu_failure_applied = None;
let third = draw_app_pass(&mut app, &ctx, &frame, vec![])
.platform_output
.ime
.expect("a drawing pass publishes the custom descriptor again")
.reset_request
.expect("the deferred request is one-shot");
assert_ne!(third, second);
}
#[test]
fn ime_delete_without_a_session_or_with_an_empty_range_does_nothing() {
let (mut app, lid) = editor("abc", 1, 1);
feed(&mut app, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 0, after_bytes: 0 })]);
assert_eq!(doc_text(&app, lid), "abc");
app.session.state.text_edit = None;
feed(&mut app, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 2, after_bytes: 0 })]);
assert_eq!(doc_text(&app, lid), "abc");
}