mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 20:58:00 -06:00
830 lines
37 KiB
Rust
830 lines
37 KiB
Rust
//! VectorCraft's outgoing IME producer: the Type tool's committed logical text, its stable owner
|
|
//! and context generation, and the explicit `InputMethod`/`Other` cause.
|
|
//!
|
|
//! The shared [`egui::ime_context`] helper does the lifecycle bookkeeping. This module is the thin
|
|
//! app adapter the helper asks for: it names the edit target from the engine's document uid and
|
|
//! TypeTool edit session, copies only a bounded committed window (never the whole document), and
|
|
//! turns accepted IME mutations into receipts around the exact engine call that applied them.
|
|
|
|
use egui::ime_context::{CommittedText, ImeContextUpdate, ImeMutationStamp, ImeObservation, ImeTarget};
|
|
use egui::output::{IMEOutput, MAX_IME_SURROUNDING_TEXT_BYTES};
|
|
use egui::{Context, Id};
|
|
use vectorcraft_engine::{Session, ViewInfo};
|
|
use vectorcraft_tools::ImeSurrounding;
|
|
|
|
use crate::VectorcraftApp;
|
|
|
|
/// The same byte limit the fork's [`egui::output::IMESurroundingText::from_text`] enforces. Asking
|
|
/// the engine for exactly this window avoids cloning a large document just to discard most of it.
|
|
pub(crate) const SURROUNDING_LIMIT: usize = MAX_IME_SURROUNDING_TEXT_BYTES;
|
|
|
|
/// Incoming selection provenance, never part of the immutable native target identity. Use the
|
|
/// tool's actual offsets: the outgoing logical snapshot collapses offsets inside marked text.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) struct IncomingSelection {
|
|
target: ImeTarget,
|
|
anchor: u64,
|
|
caret: u64,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub(crate) enum IncomingComposition {
|
|
Active(IncomingSelection),
|
|
/// An empty preedit cleared the preview; its separate terminal mutation is still pending.
|
|
TerminalPending(IncomingSelection),
|
|
Stale,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
struct OutputContext {
|
|
owner: Id,
|
|
generation: u64,
|
|
purpose: egui::IMEPurpose,
|
|
}
|
|
|
|
impl From<&IMEOutput> for OutputContext {
|
|
/// Copy the output's owner, context generation and purpose into the comparable publication
|
|
/// identity.
|
|
fn from(output: &IMEOutput) -> Self {
|
|
Self { owner: output.owner, generation: output.context_generation, purpose: output.purpose }
|
|
}
|
|
}
|
|
|
|
/// A guard can remain blocked without an acceptable token (suspension/allocation failure).
|
|
/// The destination is separate from the old composition whose preview was settled.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct ResetGuard {
|
|
request: Option<egui::ImeResetId>,
|
|
context: u64,
|
|
destination: Option<IncomingSelection>,
|
|
committed: Option<ImeMutationStamp>,
|
|
output: Option<OutputContext>,
|
|
suspended: bool,
|
|
deferred: bool,
|
|
/// Settled before the UI ran: this pass's token is retired and only the post-UI finalizer,
|
|
/// after the descriptor is confirmed, may bind the replacement.
|
|
pending: bool,
|
|
}
|
|
|
|
/// Temp-memory key marking whether this viewport is currently inside a UI pass.
|
|
fn mode_id(viewport: egui::ViewportId) -> Id {
|
|
Id::unique(("vectorcraft.ime.in-ui-pass", viewport))
|
|
}
|
|
/// Temp-memory key for the pass number and descriptor identity observed during this viewport's UI
|
|
/// pass.
|
|
fn output_id(viewport: egui::ViewportId) -> Id {
|
|
Id::unique(("vectorcraft.ime.pass-output", viewport))
|
|
}
|
|
|
|
/// Install before the host's first pass. Callbacks borrow their own Ui's Context, never capture it.
|
|
pub(crate) fn install_hooks(ctx: &Context) {
|
|
let installed = Id::unique("vectorcraft.ime.mode-hooks-installed");
|
|
let first = ctx.data_mut(|data| {
|
|
if data.get_temp::<bool>(installed).unwrap_or(false) {
|
|
false
|
|
} else {
|
|
data.insert_temp(installed, true);
|
|
true
|
|
}
|
|
});
|
|
if !first {
|
|
return;
|
|
}
|
|
ctx.on_begin_pass(
|
|
"VectorCraft IME pass begin",
|
|
std::sync::Arc::new(|ui| {
|
|
let ctx = ui.ctx();
|
|
let id = mode_id(ctx.viewport_id());
|
|
ctx.data_mut(|data| data.insert_temp(id, true));
|
|
}),
|
|
);
|
|
ctx.on_end_pass(
|
|
"VectorCraft IME pass end",
|
|
std::sync::Arc::new(|ui| {
|
|
let ctx = ui.ctx();
|
|
let id = mode_id(ctx.viewport_id());
|
|
ctx.data_mut(|data| data.insert_temp(id, false));
|
|
}),
|
|
);
|
|
}
|
|
|
|
/// The base eligibility every custom-descriptor consumer shares: a hidden viewport, the Home
|
|
/// screen, a modal dialog, the palette, a file sheet, an unfocused window, another egui keyboard
|
|
/// owner or no Type-tool text edit all mean this pass publishes a stock descriptor or none. One
|
|
/// helper so the pre-UI route test, the post-UI finalizer and the end-of-pass observation cannot
|
|
/// drift apart; Home in particular is an ineligible route even though the Type tool keeps its
|
|
/// target across it.
|
|
fn pass_eligible(app: &VectorcraftApp, ctx: &Context) -> bool {
|
|
!app.viewport_hidden(ctx)
|
|
&& !crate::menus::home_showing(app)
|
|
&& app.ui.dialog.is_none()
|
|
&& !app.ui.palette_open
|
|
&& !app.file_sheet_open()
|
|
&& ctx.input(|input| input.focused)
|
|
&& !ctx.egui_wants_keyboard_input()
|
|
&& app.session.tool_wants_text()
|
|
}
|
|
|
|
/// The one route test every allocation goes through: a request exists only when this pass can
|
|
/// publish the custom descriptor that consumes it. egui keeps the latest request in `Memory` until
|
|
/// such a descriptor arrives, so allocating on a hidden, Home, stock or geometry-less route would
|
|
/// attach a foreign token to whatever stock field publishes next. An ineligible route retires the
|
|
/// local guard instead; a later eligible activation allocates a fresh request.
|
|
fn route_live(app: &mut VectorcraftApp, ctx: &Context) -> bool {
|
|
if !pass_eligible(app, ctx) {
|
|
return false;
|
|
}
|
|
let view = app.view_info();
|
|
target(&mut app.session, view).is_some() && app.session.tool_ime_caret(view).is_some()
|
|
}
|
|
|
|
/// Allocate a reset request on an eligible custom-editor route and replace the guard with the
|
|
/// confirmed destination, commit stamp and prior output identity; suspend instead when the route
|
|
/// cannot publish.
|
|
fn request_guard(app: &mut VectorcraftApp, ctx: &Context, deferred: bool) -> bool {
|
|
if !route_live(app, ctx) {
|
|
suspend(app, ctx);
|
|
return false;
|
|
}
|
|
let viewport = ctx.viewport_id();
|
|
let destination = incoming_selection(app);
|
|
let view = app.view_info();
|
|
let committed = stamp(&mut app.session, view);
|
|
let output = app.ime_resets.get(&viewport).and_then(|guard| guard.output);
|
|
let request = ctx.request_ime_reset(viewport);
|
|
#[cfg(test)]
|
|
note_test_request(ctx);
|
|
app.ime_resets.insert(
|
|
viewport,
|
|
ResetGuard {
|
|
request,
|
|
context: crate::graphics::context_tag(ctx),
|
|
destination,
|
|
committed,
|
|
output,
|
|
suspended: false,
|
|
deferred,
|
|
pending: false,
|
|
},
|
|
);
|
|
app.ime_compositions.insert(viewport, IncomingComposition::Stale);
|
|
true
|
|
}
|
|
|
|
/// Temp-memory key for the test-only count of IME reset requests since the last take.
|
|
#[cfg(test)]
|
|
fn request_count_id() -> Id {
|
|
Id::unique("vectorcraft.ime.test-request-count")
|
|
}
|
|
|
|
/// Increment the test-only request counter for every reset allocation, including intermediates
|
|
/// superseded before publication.
|
|
#[cfg(test)]
|
|
fn note_test_request(ctx: &Context) {
|
|
ctx.data_mut(|data| {
|
|
let count = data.get_temp::<u64>(request_count_id()).unwrap_or(0);
|
|
data.insert_temp(request_count_id(), count.saturating_add(1));
|
|
});
|
|
}
|
|
|
|
/// Tests read how many guards were requested since the last call. This makes an intermediate
|
|
/// pre-settlement request visible even when the final token is stable.
|
|
#[cfg(test)]
|
|
pub(crate) fn take_request_count(ctx: &Context) -> u64 {
|
|
ctx.data_mut(|data| data.remove_temp::<u64>(request_count_id()).unwrap_or(0))
|
|
}
|
|
|
|
/// True only while a pointer/navigation/command notice is waiting for its real later handler.
|
|
/// Destination-dependent raw input must not mutate the old caret until that handler ran.
|
|
pub(crate) fn deferred(app: &VectorcraftApp, ctx: &Context) -> bool {
|
|
app.ime_resets.get(&ctx.viewport_id()).is_some_and(|guard| guard.deferred)
|
|
}
|
|
|
|
/// Retire a live guard when this pass cannot publish the custom editor at all (the route lost
|
|
/// its target or caret geometry, or the viewport is hidden). This runs before any reset marker is
|
|
/// considered, so a marker for the old request cannot complete a route that no longer exists.
|
|
pub(crate) fn suspend_if_route_lost(app: &mut VectorcraftApp, ctx: &Context) {
|
|
let viewport = ctx.viewport_id();
|
|
if app.ime_resets.get(&viewport).is_none_or(|guard| guard.suspended) {
|
|
return;
|
|
}
|
|
if !route_live(app, ctx) {
|
|
suspend(app, ctx);
|
|
}
|
|
}
|
|
|
|
/// Whether this viewport has a guard, including a suspended guard that currently owns no request.
|
|
pub(crate) fn blocked(app: &VectorcraftApp, ctx: &Context) -> bool {
|
|
app.ime_resets.contains_key(&ctx.viewport_id())
|
|
}
|
|
|
|
/// Return the reset request awaiting native completion for this viewport; test helper.
|
|
#[cfg(test)]
|
|
pub(crate) fn pending_request(app: &VectorcraftApp, ctx: &Context) -> Option<egui::ImeResetId> {
|
|
app.ime_resets.get(&ctx.viewport_id()).and_then(|guard| guard.request)
|
|
}
|
|
|
|
/// A notice precedes a later pointer/navigation/command handler. Retire the old request and keep
|
|
/// its suffix closed until that handler has established the real destination. No request is
|
|
/// allocated here: the final handler may still invalidate the route (a click that ends editing, a
|
|
/// modal, a stock field), and a token allocated now would survive in egui's memory for the next
|
|
/// descriptor. The eligible finalizer allocates for the confirmed destination instead.
|
|
pub(crate) fn defer_interrupt(app: &mut VectorcraftApp, ctx: &Context) -> bool {
|
|
let live = route_live(app, ctx);
|
|
let was_blocked = blocked(app, ctx);
|
|
// Settle with the old suffix still fenced off, but never allocate: the raw suffix after this
|
|
// notice belongs to the old caret whether or not the final route is still a custom editor.
|
|
let settled = interrupt_incoming_impl(app, ctx, Fence::Deferred);
|
|
if settled || was_blocked {
|
|
// The finalizer owns the token, but this pass must keep a guard so the old marker cannot
|
|
// complete and so a confirmed final descriptor requests its replacement. A new guard here
|
|
// has no request: `needs_replacement` sees `deferred` and allocates later.
|
|
place_deferred_guard(app, ctx);
|
|
}
|
|
if !live {
|
|
// No custom editor owns this pass. Keep the guard retired until an eligible activation,
|
|
// but leave its stale latch in place so no old commit/preedit is accepted meanwhile.
|
|
if let Some(guard) = app.ime_resets.get_mut(&ctx.viewport_id()) {
|
|
guard.request = None;
|
|
guard.deferred = true;
|
|
guard.suspended = true;
|
|
}
|
|
}
|
|
settled || blocked(app, ctx)
|
|
}
|
|
|
|
/// Retire the current request but keep the guard awaiting the finalizer's confirmed destination.
|
|
fn place_deferred_guard(app: &mut VectorcraftApp, ctx: &Context) {
|
|
let viewport = ctx.viewport_id();
|
|
let destination = incoming_selection(app);
|
|
let view = app.view_info();
|
|
let committed = stamp(&mut app.session, view);
|
|
match app.ime_resets.get_mut(&viewport) {
|
|
Some(guard) => {
|
|
guard.request = None;
|
|
guard.deferred = true;
|
|
guard.pending = false;
|
|
}
|
|
None => {
|
|
let output = None;
|
|
app.ime_resets.insert(
|
|
viewport,
|
|
ResetGuard {
|
|
request: None,
|
|
context: crate::graphics::context_tag(ctx),
|
|
destination,
|
|
committed,
|
|
output,
|
|
suspended: false,
|
|
deferred: true,
|
|
pending: false,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
app.ime_compositions.insert(viewport, IncomingComposition::Stale);
|
|
}
|
|
|
|
/// Pre-UI settlement. The old token is retired and the ordered latch is kept, but nothing is
|
|
/// allocated: the pass's final descriptor does not exist yet, so a token bound here could outlive
|
|
/// the route (a later palette/dialog/Home) in egui's memory. The post-UI finalizer, which confirms
|
|
/// the descriptor, binds the replacement for `pending`.
|
|
fn mark_pending(app: &mut VectorcraftApp, ctx: &Context) {
|
|
let viewport = ctx.viewport_id();
|
|
let destination = incoming_selection(app);
|
|
let view = app.view_info();
|
|
let committed = stamp(&mut app.session, view);
|
|
match app.ime_resets.get_mut(&viewport) {
|
|
Some(guard) => {
|
|
guard.request = None;
|
|
guard.deferred = false;
|
|
guard.pending = true;
|
|
}
|
|
None => {
|
|
app.ime_resets.insert(
|
|
viewport,
|
|
ResetGuard {
|
|
request: None,
|
|
context: crate::graphics::context_tag(ctx),
|
|
destination,
|
|
committed,
|
|
output: None,
|
|
suspended: false,
|
|
deferred: false,
|
|
pending: true,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
app.ime_compositions.insert(viewport, IncomingComposition::Stale);
|
|
}
|
|
|
|
/// Retire the guard's request and clear its deferred and pending flags while keeping the guard
|
|
/// blocked until an eligible route allocates a replacement.
|
|
fn suspend(app: &mut VectorcraftApp, ctx: &Context) {
|
|
if let Some(guard) = app.ime_resets.get_mut(&ctx.viewport_id()) {
|
|
guard.request = None;
|
|
guard.suspended = true;
|
|
guard.deferred = false;
|
|
guard.pending = false;
|
|
}
|
|
}
|
|
|
|
/// A close request ends this viewport's IME route without publishing a custom descriptor. Settle
|
|
/// any preview without a token (a later stock field must not inherit one) and retire the guard so
|
|
/// a cancelled close rearms on a fresh generation.
|
|
pub(crate) fn close_viewport(app: &mut VectorcraftApp, ctx: &Context) {
|
|
let viewport = ctx.viewport_id();
|
|
interrupt_incoming_impl(app, ctx, Fence::Unbound);
|
|
app.ime_compositions.remove(&viewport);
|
|
suspend(app, ctx);
|
|
}
|
|
|
|
/// Only this ordered raw control can lift an interrupted guard. Call-return and old terminals
|
|
/// do not acknowledge it. A missing, suspended, deferred or pending guard is inert: in particular
|
|
/// this must not synchronize an active composition into a fresh guard after `controls_without_editor`.
|
|
pub(crate) fn complete(app: &mut VectorcraftApp, ctx: &Context, request: egui::ImeResetId) -> bool {
|
|
let viewport = ctx.viewport_id();
|
|
if !app.ime_resets.get(&viewport).is_some_and(|guard| !guard.suspended && !guard.deferred && !guard.pending) {
|
|
return false;
|
|
}
|
|
// This runs before the UI: a changed composition settles `pending` and its finalizer, after
|
|
// the descriptor is confirmed, binds any replacement token.
|
|
sync_incoming_impl(app, ctx, Fence::Pending);
|
|
let destination = incoming_selection(app);
|
|
if !app.ime_resets.get(&viewport).is_some_and(|guard| {
|
|
!guard.suspended
|
|
&& !guard.deferred
|
|
&& !guard.pending
|
|
&& guard.request == Some(request)
|
|
&& guard.context == crate::graphics::context_tag(ctx)
|
|
&& request.viewport_id() == viewport
|
|
&& guard.destination == destination
|
|
}) {
|
|
return false;
|
|
}
|
|
app.ime_resets.remove(&viewport);
|
|
app.ime_compositions.remove(&viewport);
|
|
app.ime_marked = None;
|
|
if app.ime_resets.is_empty() {
|
|
app.ime_discard = false;
|
|
}
|
|
true
|
|
}
|
|
|
|
/// This path owns controls only when the custom editor cannot process ANY raw mutations.
|
|
/// Retire its activation before walking controls; leave the stock field's text/keys intact.
|
|
pub(crate) fn controls_without_editor(app: &mut VectorcraftApp, ctx: &Context) {
|
|
suspend(app, ctx);
|
|
let controls = ctx.input_mut(|input| {
|
|
let mut controls = Vec::new();
|
|
input.events.retain(|event| {
|
|
if let egui::Event::ImeResetCompleted { request_id } = event {
|
|
controls.push(*request_id);
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
});
|
|
controls
|
|
});
|
|
for request in controls {
|
|
complete(app, ctx, request);
|
|
}
|
|
}
|
|
|
|
/// Clear the previous pass's caret rectangle and published descriptor identity before this
|
|
/// viewport's UI runs.
|
|
pub(crate) fn begin_ui(app: &mut VectorcraftApp, ctx: &Context) {
|
|
app.ime_caret_rect = None;
|
|
let id = output_id(ctx.viewport_id());
|
|
ctx.data_mut(|data| data.remove::<(u64, OutputContext)>(id));
|
|
}
|
|
|
|
/// Every UI exit, including discarded/stock/no-geometry output, observes this pass's descriptor.
|
|
/// Losing publication retires R1; reactivation allocates R2 even for the same model/caret.
|
|
pub(crate) fn finish_ui(app: &mut VectorcraftApp, ctx: &Context) {
|
|
let viewport = ctx.viewport_id();
|
|
let custom = ctx
|
|
.data(|data| data.get_temp::<(u64, OutputContext)>(output_id(viewport)))
|
|
.filter(|(pass, _)| *pass == ctx.cumulative_pass_nr_for(viewport))
|
|
.map(|(_, key)| key);
|
|
if !eligible(app, ctx, custom) {
|
|
suspend(app, ctx);
|
|
return;
|
|
}
|
|
// Only an eligible custom route may settle an active composition: doing this for a stock
|
|
// field could allocate a request that core would attach to that field's descriptor.
|
|
sync_incoming(app, ctx);
|
|
let actual = ctx.output(|output| output.ime.as_ref().map(OutputContext::from));
|
|
if !eligible_with(app, ctx, custom, actual) {
|
|
suspend(app, ctx);
|
|
return;
|
|
}
|
|
let destination = incoming_selection(app);
|
|
let Some(custom) = custom else {
|
|
suspend(app, ctx);
|
|
return;
|
|
};
|
|
let replacement = app.ime_resets.get(&viewport).is_some_and(|guard| needs_replacement(guard, destination, custom));
|
|
if replacement && !request_guard(app, ctx, false) {
|
|
return;
|
|
}
|
|
if let Some(guard) = app.ime_resets.get_mut(&viewport) {
|
|
guard.output = Some(custom);
|
|
}
|
|
}
|
|
|
|
/// Whether this pass can publish a custom editor and the descriptor recorded during the pass is the
|
|
/// one actually published.
|
|
fn eligible(app: &VectorcraftApp, ctx: &Context, custom: Option<OutputContext>) -> bool {
|
|
let actual = ctx.output(|output| output.ime.as_ref().map(OutputContext::from));
|
|
eligible_with(app, ctx, custom, actual)
|
|
}
|
|
|
|
/// Require a custom descriptor, a matching actual output and an eligible route before an IME guard
|
|
/// may settle or re-arm.
|
|
fn eligible_with(app: &VectorcraftApp, ctx: &Context, custom: Option<OutputContext>, actual: Option<OutputContext>) -> bool {
|
|
custom.is_some() && actual == custom && pass_eligible(app, ctx)
|
|
}
|
|
|
|
/// Whether a guard needs a new fence because it was suspended, deferred or pending, its destination
|
|
/// changed, or its published descriptor identity changed.
|
|
fn needs_replacement(guard: &ResetGuard, destination: Option<IncomingSelection>, custom: OutputContext) -> bool {
|
|
guard.suspended || guard.deferred || guard.pending || guard.destination != destination || guard.output.is_some_and(|previous| previous != custom)
|
|
}
|
|
|
|
/// A full pass calls logic before UI; absence at that intermediate point is not publication
|
|
/// loss. Standalone run_logic has no fresh geometry and must retire a pending activation once.
|
|
pub(crate) fn finish_logic(app: &mut VectorcraftApp, ctx: &Context) {
|
|
let id = mode_id(ctx.viewport_id());
|
|
if !ctx.data(|data| data.get_temp::<bool>(id).unwrap_or(false)) {
|
|
// A logic-only iteration has no fresh custom descriptor: settle a changed preview without
|
|
// ever allocating a request (in particular for a hidden window with an external edit).
|
|
sync_incoming_unbound(app, ctx);
|
|
suspend(app, ctx);
|
|
}
|
|
}
|
|
|
|
/// Read the current Type-tool target and its anchor and caret offsets, or `None` when no text edit
|
|
/// target or selection options are available.
|
|
fn incoming_selection(app: &mut VectorcraftApp) -> Option<IncomingSelection> {
|
|
let view = app.view_info();
|
|
let target = target(&mut app.session, view)?;
|
|
let options = app.session.tool_options();
|
|
Some(IncomingSelection { target, anchor: options.get("anchor")?.as_u64()?, caret: options.get("caret")?.as_u64()? })
|
|
}
|
|
|
|
/// Record a successfully accepted preedit. Accepted IME deletes refresh this selection without
|
|
/// invalidating the commit that belongs to them.
|
|
pub(crate) fn remember_incoming(app: &mut VectorcraftApp, ctx: &Context, preedit_empty: Option<bool>) {
|
|
let viewport = ctx.viewport_id();
|
|
let Some(selection) = incoming_selection(app) else { return };
|
|
let next = match (preedit_empty, app.ime_compositions.get(&viewport)) {
|
|
(Some(false), _) | (None, Some(IncomingComposition::Active(_))) => IncomingComposition::Active(selection),
|
|
(Some(true), _) | (None, Some(IncomingComposition::TerminalPending(_))) => IncomingComposition::TerminalPending(selection),
|
|
_ => return,
|
|
};
|
|
app.ime_compositions.insert(viewport, next);
|
|
}
|
|
|
|
/// How an incoming-composition settlement relates to a reset request.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum Fence {
|
|
/// Settle and allocate a request for this pass's custom descriptor.
|
|
Bound,
|
|
/// Settle and close the raw suffix, but leave the token to the finalizer that confirms the
|
|
/// destination after the real pointer/navigation/command handler ran.
|
|
Deferred,
|
|
/// Settle before the UI without allocating or claiming a deferred notice: the ordered latch
|
|
/// stays closed, text routing is unchanged, and the post-UI finalizer binds the replacement.
|
|
Pending,
|
|
/// Settle without a request: no custom editor owns this pass (hidden, stock, no geometry).
|
|
Unbound,
|
|
}
|
|
|
|
/// Keep already-previewed marked text at its original position, without reinserting it through
|
|
/// `tool_text`: the caret or document may already have changed. Settle with no request on a route
|
|
/// that has no custom editor (a stock field, a hidden window); core could attach such a token to
|
|
/// the stock descriptor.
|
|
fn interrupt_incoming_unbound(app: &mut VectorcraftApp, ctx: &Context) {
|
|
interrupt_incoming_impl(app, ctx, Fence::Unbound);
|
|
}
|
|
|
|
/// Returns whether an active/terminal preview was settled.
|
|
fn interrupt_incoming_impl(app: &mut VectorcraftApp, ctx: &Context, fence: Fence) -> bool {
|
|
let viewport = ctx.viewport_id();
|
|
let previous = app.ime_compositions.get(&viewport).copied();
|
|
let Some(IncomingComposition::Active(expected) | IncomingComposition::TerminalPending(expected)) = previous else { return false };
|
|
if fence == Fence::Unbound {
|
|
// No fence will be bound on this route, so leave no Stale latch that only a marker could
|
|
// clear. The composition is settled and the adapter returns to Clear.
|
|
app.ime_compositions.remove(&viewport);
|
|
} else {
|
|
app.ime_compositions.insert(viewport, IncomingComposition::Stale);
|
|
}
|
|
app.ime_marked = None;
|
|
app.ime_discard = true;
|
|
let view = app.view_info();
|
|
if app.session.tool_composing() && target(&mut app.session, view) == Some(expected.target) {
|
|
match app.session.commit_interaction() {
|
|
Ok(()) => app.session.set_tool_option("commitTyping", &serde_json::Value::Null),
|
|
Err(error) => app.status(error.to_string()),
|
|
}
|
|
}
|
|
match fence {
|
|
Fence::Bound => {
|
|
// Ask for the fence only after settlement: the guard's destination and committed stamp
|
|
// are the post-commit ones, so an eligible pass does not immediately supersede it.
|
|
// Refusal (the route became ineligible) retires the local guard and its latch.
|
|
if !request_guard(app, ctx, false) {
|
|
app.ime_compositions.remove(&viewport);
|
|
}
|
|
}
|
|
Fence::Deferred => {
|
|
if let Some(guard) = app.ime_resets.get_mut(&viewport) {
|
|
guard.request = None;
|
|
guard.deferred = true;
|
|
guard.pending = false;
|
|
}
|
|
}
|
|
Fence::Pending => mark_pending(app, ctx),
|
|
Fence::Unbound => {}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// The finalizer form: check application changes after the canvas/panels ran. A live preview
|
|
/// ending on its own is an interruption even if the caret happened to land at its old offset. No
|
|
/// prior composition means a direct commit remains legitimate. Only a post-UI call may bind.
|
|
pub(crate) fn sync_incoming(app: &mut VectorcraftApp, ctx: &Context) {
|
|
sync_incoming_impl(app, ctx, Fence::Bound);
|
|
}
|
|
|
|
/// Pre-UI form: the pass's descriptor does not exist yet, so a changed composition or guard is
|
|
/// settled `pending` and the post-UI finalizer binds the replacement after confirming it.
|
|
pub(crate) fn sync_incoming_before_ui(app: &mut VectorcraftApp, ctx: &Context) {
|
|
sync_incoming_impl(app, ctx, Fence::Pending);
|
|
}
|
|
|
|
/// Settlement for a pass that publishes no custom descriptor (standalone hidden logic). It settles
|
|
/// an interrupted preview without allocating a token that a later stock field could consume.
|
|
fn sync_incoming_unbound(app: &mut VectorcraftApp, ctx: &Context) {
|
|
sync_incoming_impl(app, ctx, Fence::Unbound);
|
|
}
|
|
|
|
/// Settle an incoming composition whose selection, composing state, session or context no longer
|
|
/// matches the recorded expectation, applying the fence policy; with no recorded composition, also
|
|
/// retire or replace a guard whose destination, commit stamp, context or pending state changed.
|
|
fn sync_incoming_impl(app: &mut VectorcraftApp, ctx: &Context, fence: Fence) {
|
|
let previous = app.ime_compositions.get(&ctx.viewport_id()).copied();
|
|
let expected = match previous {
|
|
Some(IncomingComposition::Active(selection) | IncomingComposition::TerminalPending(selection)) => selection,
|
|
_ => {
|
|
if !blocked(app, ctx) {
|
|
return;
|
|
}
|
|
let selection = incoming_selection(app);
|
|
let view = app.view_info();
|
|
let committed = stamp(&mut app.session, view);
|
|
if app.ime_resets.get(&ctx.viewport_id()).is_some_and(|guard| {
|
|
!guard.suspended
|
|
&& !guard.deferred
|
|
&& (guard.pending
|
|
|| guard.destination != selection
|
|
|| guard.committed != committed
|
|
|| guard.context != crate::graphics::context_tag(ctx))
|
|
}) {
|
|
match fence {
|
|
Fence::Bound => {
|
|
request_guard(app, ctx, false);
|
|
}
|
|
Fence::Pending => mark_pending(app, ctx),
|
|
Fence::Deferred | Fence::Unbound => suspend(app, ctx),
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
let view = app.view_info();
|
|
if incoming_selection(app) != Some(expected)
|
|
|| matches!(previous, Some(IncomingComposition::Active(_))) && !app.session.tool_composing()
|
|
|| !app.session.tool_ime_session_matches(view)
|
|
{
|
|
interrupt_incoming_impl(app, ctx, fence);
|
|
}
|
|
}
|
|
|
|
/// Stable identity of the Type tool's current edit target.
|
|
///
|
|
/// The owner is the document uid plus the edited text node; the replacement epoch is the Type
|
|
/// tool's edit session, so leaving and re-entering the same text object starts a fresh IME
|
|
/// context while ordinary typing, selection and caret moves keep the old one.
|
|
pub(crate) fn target(session: &mut Session, view: ViewInfo) -> Option<ImeTarget> {
|
|
session.tool_ime_target(view).map(|(document, node, session)| ImeTarget::new(Id::unique(("vectorcraft-type-ime", document, node)), session))
|
|
}
|
|
|
|
/// The bounded committed text and UTF-8 offsets, or `None` for an explicit unavailability reason.
|
|
pub(crate) fn committed(session: &mut Session, view: ViewInfo) -> Option<(String, usize, usize)> {
|
|
match session.tool_ime_surrounding(SURROUNDING_LIMIT, view) {
|
|
ImeSurrounding::Available { text, cursor, anchor } => Some((text, cursor, anchor)),
|
|
ImeSurrounding::Unavailable(_) => None,
|
|
}
|
|
}
|
|
|
|
/// A receipt stamp of the current committed context, for a successful mutation's boundary.
|
|
///
|
|
/// `None` means there is no edit target at all. An explicit unavailable query (a selection wider
|
|
/// than the limit, invalid offsets, or a typing session the document changed under) is still a
|
|
/// stamp: [`note_ime_apply`] refuses to earn a receipt from an unavailable boundary, so the
|
|
/// mutation stays [`egui::output::ImeChangeCause::Other`].
|
|
pub(crate) fn stamp(session: &mut Session, view: ViewInfo) -> Option<ImeMutationStamp> {
|
|
target(session, view)?;
|
|
let revision = session.active().map_or(0, |document| document.revision);
|
|
Some(match committed(session, view) {
|
|
Some((text, cursor, anchor)) => ImeMutationStamp::from_committed(&text, cursor, anchor, None),
|
|
// Unavailable stamps need a producer revision to order two boundaries: without one,
|
|
// before and after compare equal and an application edit cannot be distinguished from
|
|
// an unchanged unavailable snapshot.
|
|
None => ImeMutationStamp::unavailable_at_revision(revision),
|
|
})
|
|
}
|
|
|
|
/// Record an accepted IME commit/delete after it succeeded, comparing the committed context
|
|
/// immediately before and after the engine call.
|
|
///
|
|
/// A stale target, a no-op (`before == after`), or an unavailable receipt earns nothing: the
|
|
/// tracker then reports `Other` for any snapshot change it cannot prove came from the IME.
|
|
pub(crate) fn note_ime_apply(
|
|
app: &mut VectorcraftApp,
|
|
ctx: &Context,
|
|
view: ViewInfo,
|
|
target_before: Option<ImeTarget>,
|
|
before: Option<ImeMutationStamp>,
|
|
) {
|
|
let (Some(target_before), Some(before)) = (target_before, before) else { return };
|
|
if target(&mut app.session, view) != Some(target_before) {
|
|
return;
|
|
}
|
|
let Some(after) = stamp(&mut app.session, view) else { return };
|
|
if before == after {
|
|
return;
|
|
}
|
|
// A receipt must describe the committed text on both sides of the mutation. An unavailable
|
|
// snapshot (an external edit left the typing session stale, or a selection is wider than the
|
|
// limit) cannot prove what the IME changed, so such a mutation stays `Other`.
|
|
if before.surrounding_text().available().is_none() || after.surrounding_text().available().is_none() {
|
|
return;
|
|
}
|
|
app.ime_context.note_input_method_apply(ctx.viewport_id(), ctx.cumulative_frame_nr_for(ctx.viewport_id()), target_before, before, after);
|
|
}
|
|
|
|
/// Record an application-origin text mutation (typing, paste, Backspace/Delete, a menu edit, a
|
|
/// correction). `Other` then dominates the whole frame, including any IME receipt in it.
|
|
///
|
|
/// `before == after` is a no-op and is not reported. A changed target (editing ended) is left to
|
|
/// the normal context-transition rule.
|
|
pub(crate) fn note_application_edit(
|
|
app: &mut VectorcraftApp,
|
|
ctx: &Context,
|
|
view: ViewInfo,
|
|
target_before: Option<ImeTarget>,
|
|
before: Option<ImeMutationStamp>,
|
|
) {
|
|
let (Some(target_before), Some(before)) = (target_before, before) else { return };
|
|
if target(&mut app.session, view) != Some(target_before) {
|
|
return;
|
|
}
|
|
let Some(after) = stamp(&mut app.session, view) else { return };
|
|
if before == after {
|
|
return;
|
|
}
|
|
app.ime_context.note_application_change(ctx.viewport_id(), ctx.cumulative_frame_nr_for(ctx.viewport_id()), target_before);
|
|
}
|
|
|
|
/// Observe the Type tool once, near the end of this viewport's pass.
|
|
///
|
|
/// `focused` is the canvas's own decision (the Type tool wants text, no other egui field owns the
|
|
/// keyboard, and no dialog or palette covers it). Pass `false` for a hidden/absent owner. The
|
|
/// tracker gets `cumulative_frame_nr_for` and `cumulative_pass_nr_for` separately, so cause
|
|
/// accumulates across a frame's discarded passes while an unchanged second pass keeps its cause.
|
|
pub(crate) fn observe(app: &mut VectorcraftApp, ctx: &Context, focused: bool) -> Option<ImeContextUpdate> {
|
|
let viewport = ctx.viewport_id();
|
|
if !focused {
|
|
return app.ime_context.observe(viewport, None);
|
|
}
|
|
let view = app.view_info();
|
|
let Some(target) = target(&mut app.session, view) else {
|
|
return app.ime_context.observe(viewport, None);
|
|
};
|
|
let committed = committed(&mut app.session, view);
|
|
let revision = app.session.active().map_or(0, |document| document.revision);
|
|
let observation = ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), target)
|
|
.focused(true)
|
|
.purpose(egui::IMEPurpose::Normal)
|
|
.committed(committed.as_ref().map(|(text, cursor, anchor)| CommittedText::new(text, *cursor, *anchor)))
|
|
// The helper keeps this only for an unavailable snapshot; available text still compares
|
|
// its exact bounded contents and selection.
|
|
.unavailable_revision(revision);
|
|
app.ime_context.observe(viewport, Some(observation))
|
|
}
|
|
|
|
/// Mark this viewport's IME owner absent for a pass (no document, Home, a hidden canvas).
|
|
pub(crate) fn observe_none(app: &mut VectorcraftApp, ctx: &Context) {
|
|
app.ime_context.observe(ctx.viewport_id(), None);
|
|
}
|
|
|
|
/// Observe and write the outgoing descriptor once, at the end of the pass.
|
|
///
|
|
/// The canvas records only the caret geometry (`VectorcraftApp::ime_caret_rect`) while it draws.
|
|
/// By the time this runs, every dialog, floating panel, progress window and the palette has drawn
|
|
/// and ran its commands, so the owner, focused state, committed text and cause are the ones of the
|
|
/// latest completed pass. A dialog's own `TextEdit` descriptor is left alone: writing `None` here
|
|
/// would clobber it.
|
|
pub(crate) fn end_of_pass(app: &mut VectorcraftApp, ctx: &Context) {
|
|
let ours = app.ime_caret_rect.is_some() && pass_eligible(app, ctx);
|
|
if !ours {
|
|
// No custom editor owns the IME this pass (Home, no document, a dialog, a palette, a hidden
|
|
// window). Mark the context absent and let marked text stay as typed, as a click away does.
|
|
// Do not synchronize or allocate here: the stock descriptor must carry no reset token.
|
|
observe_none(app, ctx);
|
|
interrupt_incoming_unbound(app, ctx);
|
|
crate::shortcuts::keep_marked_text(app);
|
|
return;
|
|
}
|
|
sync_incoming(app, ctx);
|
|
let Some((rect, cursor_rect)) = app.ime_caret_rect else { return };
|
|
// The tool ended the composition itself: the IME must drop what it still has marked.
|
|
let mut interrupt = app.ime_marked.is_some() && !app.session.tool_composing();
|
|
if interrupt {
|
|
app.ime_marked = None;
|
|
app.ime_discard = true;
|
|
// The tool ended a composition that `sync_incoming` did not already settle (for example a
|
|
// pre-edit without recorded geometry). Publish a request-bound interruption like every
|
|
// other path, rather than an untokened `should_interrupt_composition`. If the route became
|
|
// ineligible after `ours` was computed, publish no interruption without its fence.
|
|
if !request_guard(app, ctx, false) {
|
|
interrupt = false;
|
|
}
|
|
}
|
|
let update = observe(app, ctx, true);
|
|
write_output(ctx, update, rect, cursor_rect, interrupt);
|
|
}
|
|
|
|
/// Write every field of the descriptor the shared helper returned. Geometry stays the actual
|
|
/// caret rectangle; cause and surrounding text come from the tracker, never from a placeholder.
|
|
pub(crate) fn write_output(ctx: &Context, update: Option<ImeContextUpdate>, rect: egui::Rect, cursor_rect: egui::Rect, interrupt: bool) {
|
|
let Some(update) = update else {
|
|
ctx.output_mut(|o| o.ime = None);
|
|
return;
|
|
};
|
|
ctx.output_mut(|o| {
|
|
o.ime = Some(IMEOutput {
|
|
owner: update.owner,
|
|
context_generation: update.context_generation,
|
|
purpose: update.purpose,
|
|
rect,
|
|
cursor_rect,
|
|
should_interrupt_composition: interrupt,
|
|
reset_request: None,
|
|
change_cause: update.change_cause,
|
|
surrounding_text: update.surrounding_text,
|
|
});
|
|
});
|
|
if let Some(key) = ctx.output(|output| output.ime.as_ref().map(OutputContext::from)) {
|
|
let id = output_id(ctx.viewport_id());
|
|
let pass = ctx.cumulative_pass_nr();
|
|
ctx.data_mut(|data| data.insert_temp(id, (pass, key)));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A changed context generation or IME purpose for the same owner requires a replacement reset,
|
|
/// while an unchanged descriptor keeps the existing request.
|
|
#[test]
|
|
fn same_owner_new_generation_or_purpose_requires_a_replacement_request() {
|
|
let owner = Id::unique("vectorcraft-ime-output-context-test");
|
|
let previous = OutputContext { owner, generation: 7, purpose: egui::IMEPurpose::Normal };
|
|
let guard = ResetGuard {
|
|
request: None,
|
|
context: 1,
|
|
destination: None,
|
|
committed: None,
|
|
output: Some(previous),
|
|
suspended: false,
|
|
deferred: false,
|
|
pending: false,
|
|
};
|
|
let newer_generation = OutputContext { generation: 8, ..previous };
|
|
assert_ne!(previous, newer_generation, "the same owner in a new context generation is a new output");
|
|
assert!(needs_replacement(&guard, None, newer_generation));
|
|
let other_purpose = OutputContext { purpose: egui::IMEPurpose::Password, ..previous };
|
|
assert_ne!(previous, other_purpose, "the same owner and generation with another purpose is a new output");
|
|
assert!(needs_replacement(&guard, None, other_purpose));
|
|
assert!(!needs_replacement(&guard, None, previous), "an unchanged output keeps its request");
|
|
}
|
|
}
|