Files
designcraft/crates/engine/src/tooling.rs
T

655 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tool hosting: the active tool receives pointer/key events and its actions are executed here.
use std::ops::Range;
use std::sync::Arc;
use designcraft_doc::{CellAddr, StoryId};
use designcraft_geom::Unit;
pub use designcraft_tools::SnapView;
use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, ToolContext, ToolKey};
use serde::Serialize;
use serde_json::{Value, json};
use crate::{EngineError, HistoryEntry, Interaction, Result, Session, record_undo};
/// Something the UI should do in response to a tool or command.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum UiRequest {
Dialog {
id: String,
params: Value,
},
View {
params: Value,
},
/// Open a file picker for the given purpose (`place`, `open`, `saveAs`, `export`).
Pick {
purpose: String,
params: Value,
},
}
/// View information the UI passes with pointer events.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct ViewInfo {
/// Screen pixels per point.
pub zoom: f64,
pub snap: SnapView,
pub unit: Unit,
/// The toolbox's Stroke box is in front (X): colour tools apply to strokes.
pub stroke_in_front: bool,
}
impl ViewInfo {
pub const fn at_zoom(zoom: f64) -> Self {
Self { zoom, snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false }
}
}
impl Default for ViewInfo {
fn default() -> Self {
Self::at_zoom(1.0)
}
}
impl Session {
pub fn tool_id(&self) -> &'static str {
self.tool.id()
}
pub fn set_tool(&mut self, id: &str) {
if self.tool.id() == id {
return;
}
// A composition ends with the tool that shows it: its marked text stays as typed.
if let Err(e) = self.end_composition() {
log::warn!("ending the IME composition: {e}");
}
self.tool = designcraft_tools::create(id);
self.temp_tool = None;
if matches!(self.tool.id(), "selection" | "directSelection") {
self.last_selection_tool = self.tool.id();
}
// Any other tool unloads the place cursor.
if id != "placeGun" {
self.loaded = None;
}
// Leaving the Type tool keeps the text frame selected.
if id != "type"
&& let Some(st) = self.active_mut()
&& let Some(t) = st.selection.text.take()
&& let Some(f) = t.frame.or_else(|| st.doc.story(t.story).and_then(|s| s.frames.first().copied()))
{
st.selection.items = vec![f];
st.selection.cells = None;
}
}
pub fn layout(&self) -> CanvasLayout {
match self.active() {
Some(st) => CanvasLayout::new(&st.doc, st.editing_parents),
None => CanvasLayout::default(),
}
}
fn with_ctx<T>(&mut self, view: ViewInfo, f: impl FnOnce(&mut Self, &ToolContext) -> T) -> Option<T> {
let st = self.active()?;
let doc = st.doc.clone();
let sel = st.selection.clone();
let layer = st.active_layer;
let layout = CanvasLayout::new(&doc, st.editing_parents);
let cache = self.cache.clone();
let cx = ToolContext {
doc: &doc,
selection: &sel,
cache: &cache,
layout: &layout,
zoom: view.zoom.max(1e-6),
layer,
snap: view.snap,
unit: view.unit,
stroke_in_front: view.stroke_in_front,
};
Some(f(self, &cx))
}
/// The tool pointer, key, overlay and cursor events go to: the selection tool a Cmd-held press
/// borrowed, for the rest of that gesture, or else the chosen tool.
fn gesture_tool(&mut self) -> &mut dyn designcraft_tools::Tool {
match self.temp_tool.as_deref_mut() {
Some(t) => t,
None => self.tool.as_mut(),
}
}
/// Which selection tool a Cmd-held press borrows while another tool is chosen: whichever of
/// Selection and Direct Selection was chosen last. None for the selection tools themselves,
/// Hand, Zoom and a loaded place cursor, where Cmd means something else or nothing.
fn borrowed_selection_tool(&self) -> Option<&'static str> {
match self.tool.id() {
"selection" | "directSelection" | "hand" | "zoom" | "placeGun" => None,
_ => Some(self.last_selection_tool),
}
}
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<()> {
// A press ends a composition, keeping its marked text as typed (like clicking away from
// one in a native text view); the UI tells the system IME to drop it.
if matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick) {
self.end_composition()?;
}
// Cmd held at a press: the gesture (press, drags, release) goes to the selection tool the
// chosen tool borrows, which selects, moves or resizes without a tool change (#919).
if matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick)
&& ev.mods.cmd
&& self.temp_tool.is_none()
&& !self.tool.busy()
&& let Some(id) = self.borrowed_selection_tool()
{
self.temp_tool = Some(designcraft_tools::create(id));
}
let actions = self.with_ctx(view, |s, cx| s.gesture_tool().pointer(cx, ev)).unwrap_or_default();
let r = self.run_actions(actions);
if ev.kind == PointerKind::Up && self.temp_tool.as_ref().is_some_and(|t| !t.busy()) {
self.temp_tool = None;
}
r
}
/// Dispatch a temporary view tool without replacing the active tool or its state.
/// Its actions use the same execution path as the selected tool's gestures.
pub fn pointer_with_tool(&mut self, tool: &mut dyn designcraft_tools::Tool, ev: &PointerEvent, view: ViewInfo) -> Result<()> {
let actions = self.with_ctx(view, |_, cx| tool.pointer(cx, ev)).unwrap_or_default();
self.run_actions(actions)
}
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<bool> {
// Keys belong to the IME while it composes: nothing else (Escape's deselect) takes them.
if self.tool.composing() {
return Ok(true);
}
let actions = self.with_ctx(view, |s, cx| s.gesture_tool().key(cx, key, mods)).unwrap_or_default();
let handled = !actions.is_empty();
self.run_actions(actions)?;
Ok(handled)
}
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
self.with_ctx(view, |s, cx| s.gesture_tool().overlays(cx)).unwrap_or_default()
}
pub fn cursor(&mut self, p: designcraft_geom::Point, mods: Mods, view: ViewInfo) -> Cursor {
// Cmd held over the canvas shows the selection tool a press would borrow.
if mods.cmd
&& self.temp_tool.is_none()
&& !self.tool.busy()
&& let Some(id) = self.borrowed_selection_tool()
{
let borrowed = designcraft_tools::create(id);
return self.with_ctx(view, |_, cx| borrowed.cursor(cx, p, mods)).unwrap_or_default();
}
self.with_ctx(view, |s, cx| s.gesture_tool().cursor(cx, p, mods)).unwrap_or_default()
}
/// Canvas centres of the content grabbers the Selection tool shows: the selected frame's and
/// the one under `hover`.
pub fn content_grabbers(&mut self, hover: Option<designcraft_geom::Point>, view: ViewInfo) -> Vec<designcraft_geom::Point> {
if self.tool.id() != "selection" {
return vec![];
}
self.with_ctx(view, |_, cx| designcraft_tools::select::grabbers(cx, hover).into_iter().map(|(_, c)| c).collect()).unwrap_or_default()
}
pub fn tool_busy(&self) -> bool {
self.tool.busy()
}
pub fn wants_text(&mut self) -> bool {
self.with_ctx(ViewInfo::at_zoom(1.0), |s, cx| s.gesture_tool().wants_text(cx)).unwrap_or(false)
}
/// IME marked text at the text caret (see `Tool::ime_preedit`; `active_chars` counts
/// characters of `text`). It shows in place as a preview: no undo step, nothing journaled.
pub fn tool_preedit(&mut self, text: &str, active_chars: Option<std::ops::Range<usize>>, view: ViewInfo) -> Result<()> {
let actions = self.with_ctx(view, |s, cx| s.tool.ime_preedit(cx, text, active_chars)).unwrap_or_default();
let r = self.run_actions(actions);
if r.is_err() && self.tool.composing() {
// The marked text can't show (its story went away): the composition ends with nothing
// typed, so no gesture stays open.
match self.with_ctx(view, |s, cx| s.tool.ime_preedit(cx, "", None)) {
Some(cancel) => self.run_actions(cancel)?,
None => self.tool = designcraft_tools::create(self.tool.id()),
}
}
r
}
/// The IME committed `text`: it replaces the marked text as one `text.insert` (one undo step,
/// one journaled command).
pub fn tool_ime_commit(&mut self, text: &str, view: ViewInfo) -> Result<()> {
let actions = self.with_ctx(view, |s, cx| s.tool.ime_commit(cx, text)).unwrap_or_default();
self.run_actions(actions)
}
/// Is the active tool showing uncommitted IME text?
pub fn tool_composing(&self) -> bool {
self.tool.composing()
}
/// End an IME composition from outside the IME (a click, a tool switch, another command): the
/// marked text stays as typed, as one undo step.
pub fn end_composition(&mut self) -> Result<()> {
if !self.tool.composing() {
return Ok(());
}
match self.with_ctx(ViewInfo::default(), |s, cx| s.tool.ime_end(cx)) {
Some(actions) => self.run_actions(actions),
None => {
// No document to type into: only the tool's state goes.
self.tool = designcraft_tools::create(self.tool.id());
Ok(())
}
}
}
/// The caret line (canvas space) the IME candidate window follows.
pub fn tool_ime_caret(&mut self, view: ViewInfo) -> Option<(designcraft_geom::Point, designcraft_geom::Point)> {
self.with_ctx(view, |s, cx| s.tool.ime_caret(cx)).flatten()
}
pub fn run_actions(&mut self, actions: Vec<Action>) -> Result<()> {
for a in actions {
match a {
Action::Begin(label) => self.begin_interaction(&label)?,
Action::Preview(cmd, p) => self.preview(&cmd, p)?,
Action::Commit => self.commit_interaction()?,
Action::Cancel => self.cancel_interaction(),
Action::Exec(cmd, p) => {
self.execute(&cmd, &p)?;
}
Action::Dialog(id, params) => self.ui_requests.push(UiRequest::Dialog { id, params }),
Action::SwitchTool(id) => self.set_tool(&id),
Action::View(params) => self.ui_requests.push(UiRequest::View { params }),
Action::Handled => {}
}
}
Ok(())
}
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
let st = self.doc_mut()?;
st.interaction =
Some(Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None, revision: st.revision });
Ok(())
}
/// Apply `cmd` on top of the interaction's snapshot (replacing the previous preview).
pub fn preview(&mut self, cmd: &str, params: Value) -> Result<()> {
let st = self.doc_mut()?;
let Some(it) = st.interaction.clone() else { return Err(EngineError::Other("no interaction".into())) };
st.doc = it.doc.clone();
st.selection = it.selection.clone();
// Not through `execute`: a preview is part of the gesture (an IME composition included),
// not a command that ends it.
let r = self.guarded(cmd, |s| s.execute_unguarded(cmd, &params));
if let Some(st) = self.active_mut()
&& let Some(i) = st.interaction.as_mut()
{
i.preview = Some((cmd.to_string(), params));
}
// Journal only the committed result.
if r.is_ok() {
self.journal.pop();
}
r.map(|_| ())
}
pub fn commit_interaction(&mut self) -> Result<()> {
let st = self.doc_mut()?;
let Some(it) = st.interaction.take() else { return Ok(()) };
if !Arc::ptr_eq(&it.doc, &st.doc) {
record_undo(st, HistoryEntry { label: it.label, doc: it.doc, selection: it.selection });
}
if let Some((c, p)) = it.preview {
self.journal.push((c, p));
}
// The interaction's last preview is the transform to repeat; the next one starts afresh.
self.transforms.2 = false;
Ok(())
}
pub fn cancel_interaction(&mut self) {
if let Some(st) = self.active_mut()
&& let Some(it) = st.interaction.take()
{
// Nothing changed: the document goes back to the state and revision it began with.
st.doc = it.doc;
st.selection = it.selection;
st.revision = it.revision;
if self.transforms.2 {
self.transforms.0.pop();
self.transforms.2 = false;
}
}
}
/// Cancel the interaction of the document with `uid`, active or not: a gesture that began in
/// one document and lost it to another tab ends there, leaving the document as it began.
pub fn cancel_interaction_of(&mut self, uid: u64) {
if self.active().is_some_and(|st| st.uid == uid) {
self.cancel_interaction();
} else if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid)
&& let Some(it) = st.interaction.take()
{
st.doc = it.doc;
st.selection = it.selection;
st.revision = it.revision;
}
}
}
/// The committed text around the Type tool's IME caret.
///
/// While an IME composition is active the marked text is only an interaction preview, so this
/// borrows the interaction's committed snapshot and selection instead of the previewed document.
/// `anchor` and `focus` are UTF-8 byte offsets into `text`; `focus` is the primary caret end.
#[derive(Clone, Copy, Debug)]
pub struct ImeTextContext<'a> {
/// Stable document identity (a new document gets a new value).
pub document_uid: u64,
/// Document-state identity: changes when the state is recreated even if `document_uid` is
/// preserved (File › Revert).
pub document_epoch: u64,
/// Story being edited, or the story that owns the edited table cell.
pub story: StoryId,
/// Edited table cell, when the caret is in one.
pub cell: Option<CellAddr>,
/// Committed logical story text, without the current IME pre-edit.
pub text: &'a str,
/// Selection anchor (the end that stays put under Shift-arrow selection).
pub anchor: usize,
/// Primary selection end (the caret).
pub focus: usize,
}
/// What an accepted IME mutation applied to the committed story text.
///
/// The engine reports this from the whole story, not from the bounded outgoing IME window, so a
/// successful mutation can be described exactly even when the deleted text lies outside that
/// window. `deleted_before` and `deleted_after` are byte ranges of the text before the mutation;
/// `inserted` is the text the mutation typed at the selection. Invalid or unreportable mutations
/// return `None` rather than a guessed description.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeMutation {
/// Stable document identity of the mutation.
pub document_uid: u64,
/// Document-state identity (see [`ImeTextContext::document_epoch`]).
pub document_epoch: u64,
/// Story (or the story owning the edited cell) that changed.
pub story: StoryId,
/// Edited table cell, when the caret was in one.
pub cell: Option<CellAddr>,
/// Committed selection before and after the mutation (UTF-8 byte offsets).
pub anchor_before: usize,
pub focus_before: usize,
pub anchor_after: usize,
pub focus_after: usize,
/// Committed story text length before and after the mutation.
pub text_len_before: usize,
pub text_len_after: usize,
/// Text removed before and after the selection (empty when nothing was removed).
pub deleted_before: Range<usize>,
pub deleted_after: Range<usize>,
/// Text typed at the selection, after the commit normalization.
pub inserted: String,
}
/// The identity, committed selection and text length at the IME caret.
#[derive(Clone, Copy, Debug)]
struct ImeState {
document_uid: u64,
document_epoch: u64,
story: StoryId,
cell: Option<CellAddr>,
anchor: usize,
focus: usize,
text_len: usize,
}
/// The ranges a successful `text.deleteSurrounding` reports it removed.
fn ime_ranges_from_command(r: &Value) -> Option<(Range<usize>, Range<usize>)> {
let range = |key: &str| {
let arr = r.get(key)?.as_array()?;
let start = usize::try_from(arr.first()?.as_u64()?).ok()?;
let end = usize::try_from(arr.get(1)?.as_u64()?).ok()?;
(start <= end).then_some(start..end)
};
Some((range("deletedBefore")?, range("deletedAfter")?))
}
/// The byte ranges an IME delete-surrounding request removes around a selection.
///
/// The returned pair is `(before_selection, after_selection)`. Counts exclude the selection
/// itself and the current pre-edit. Character counts are Unicode scalar values. Byte counts
/// round inward: a count that ends inside a character leaves that whole character. Invalid or
/// non-boundary selection offsets return `None` rather than guessed ranges.
pub fn ime_delete_ranges(text: &str, anchor: usize, focus: usize, before: usize, after: usize, bytes: bool) -> Option<(Range<usize>, Range<usize>)> {
let (min, max) = ime_selection_bounds(text, anchor, focus)?;
Some(if bytes { ime_byte_delete_ranges(text, min, max, before, after) } else { ime_character_delete_ranges(text, min, max, before, after) })
}
/// Validate the committed selection and return its lower and upper UTF-8 byte offsets.
fn ime_selection_bounds(text: &str, anchor: usize, focus: usize) -> Option<(usize, usize)> {
if anchor > text.len() || focus > text.len() || !text.is_char_boundary(anchor) || !text.is_char_boundary(focus) {
return None;
}
Some((anchor.min(focus), anchor.max(focus)))
}
/// Byte-counted ranges around the selection, rounded inward to character boundaries.
fn ime_byte_delete_ranges(text: &str, min: usize, max: usize, before: usize, after: usize) -> (Range<usize>, Range<usize>) {
// Start at the requested byte offset and step at most three bytes to the next character
// boundary: the scan is bounded by the count, not by the caret's offset in the story.
let from = min.saturating_sub(before).min(min);
let mut before_start = from;
while before_start < min && !text.is_char_boundary(before_start) {
before_start += 1;
}
let target = max.saturating_add(after).min(text.len());
let mut after_end = max;
for (i, c) in text[max..].char_indices() {
let end = max + i + c.len_utf8();
if end <= target {
after_end = end;
} else {
break;
}
}
(before_start..min, max..after_end)
}
/// Character-counted ranges around the selection, using Unicode scalar values.
fn ime_character_delete_ranges(text: &str, min: usize, max: usize, before: usize, after: usize) -> (Range<usize>, Range<usize>) {
let before_start = if before == 0 {
min
} else {
let mut seen = 0usize;
let mut start = 0usize;
for (i, _) in text[..min].char_indices().rev() {
seen += 1;
start = i;
if seen == before {
break;
}
}
if seen < before { 0 } else { start }
};
let mut after_end = max;
if after > 0 {
let mut seen = 0usize;
for (i, c) in text[max..].char_indices() {
seen += 1;
after_end = max + i + c.len_utf8();
if seen == after {
break;
}
}
}
(before_start..min, max..after_end)
}
impl Session {
/// The committed text and selection at the Type tool's IME caret.
///
/// Returns `None` without an insertion point, without a committed story, or while a
/// composition has no interaction snapshot to describe its committed text. The query never
/// copies the story.
pub fn ime_text_context(&self) -> Option<ImeTextContext<'_>> {
let st = self.active()?;
let (doc, ts) = if self.tool.composing() {
let it = st.interaction.as_ref()?;
(it.doc.as_ref(), it.selection.text?)
} else {
(st.doc.as_ref(), st.selection.text?)
};
let story = doc.text_story(ts.story, ts.cell)?;
if ts.anchor > story.text.len()
|| ts.focus > story.text.len()
|| !story.text.is_char_boundary(ts.anchor)
|| !story.text.is_char_boundary(ts.focus)
{
// A stale or corrupt insertion point: report unavailable rather than inventing offsets.
return None;
}
Some(ImeTextContext {
document_uid: st.uid,
document_epoch: st.identity_epoch,
story: ts.story,
cell: ts.cell,
text: &story.text,
anchor: ts.anchor,
focus: ts.focus,
})
}
/// Delete the text an IME asked to remove around the selection, as one undo step.
///
/// The mutation goes through the `text.deleteSurrounding` command, so it restores a Data Merge
/// preview, records undo, auto-sizes, journals and is panic-guarded like any other edit.
/// The selection itself and any current pre-edit are excluded. Character counts are scalar
/// values; byte counts round inward to whole characters. A selection keeps its direction and
/// is remapped past the deleted text.
pub fn ime_delete_surrounding(&mut self, before: usize, after: usize, bytes: bool) -> Result<Option<ImeMutation>> {
if !self.ime_deletion_changes_text(before, after, bytes)? {
return Ok(None);
}
let Some(pre) = self.ime_state() else { return Ok(None) };
self.end_preedit_without_typing()?;
let r = self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes}))?;
Ok(self.ime_mutation_from_command(pre, &r, String::new()))
}
/// The IME committed `text`: one `text.insert`, reported as the mutation it applied.
pub fn ime_commit(&mut self, text: &str, view: ViewInfo) -> Result<Option<ImeMutation>> {
let Some(pre) = self.ime_state() else { return self.tool_ime_commit(text, view).map(|()| None) };
self.tool_ime_commit(text, view)?;
let inserted = designcraft_tools::ime_commit_text(text);
// A commit replaces the selection: the removed ranges are empty at its two ends.
let (start, end) = (pre.anchor.min(pre.focus), pre.anchor.max(pre.focus));
Ok(self.ime_mutation_from_states(pre, start..start, end..end, inserted))
}
/// Delete surrounding text and insert the IME commit in one undo step.
///
/// This is the normal delete-surrounding-then-commit batch: both mutations are one
/// `text.deleteSurrounding` command, so one Undo restores the state before the batch.
pub fn ime_commit_after_delete(&mut self, text: &str, view: ViewInfo, before: usize, after: usize, bytes: bool) -> Result<Option<ImeMutation>> {
if !self.ime_deletion_changes_text(before, after, bytes)? {
return self.ime_commit(text, view);
}
let Some(pre) = self.ime_state() else { return Ok(None) };
self.end_preedit_without_typing()?;
let inserted = designcraft_tools::ime_commit_text(text);
let r = self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes, "commit": inserted.clone()}))?;
Ok(self.ime_mutation_from_command(pre, &r, inserted))
}
/// The identity, committed selection and text length of the IME caret's story.
fn ime_state(&self) -> Option<ImeState> {
let context = self.ime_text_context()?;
Some(ImeState {
document_uid: context.document_uid,
document_epoch: context.document_epoch,
story: context.story,
cell: context.cell,
anchor: context.anchor,
focus: context.focus,
text_len: context.text.len(),
})
}
/// Report the mutation the delete command applied, from its ranges and the states around it.
fn ime_mutation_from_command(&self, before: ImeState, r: &Value, inserted: String) -> Option<ImeMutation> {
let (deleted_before, deleted_after) = ime_ranges_from_command(r)?;
self.ime_mutation_from_states(before, deleted_before, deleted_after, inserted)
}
/// Return a mutation only when the document uid/epoch, story and cell still match; otherwise report no mutation.
fn ime_mutation_from_states(
&self,
before: ImeState,
deleted_before: Range<usize>,
deleted_after: Range<usize>,
inserted: String,
) -> Option<ImeMutation> {
let after = self.ime_state()?;
// The mutation must have stayed in the story it began in.
if after.document_uid != before.document_uid
|| after.document_epoch != before.document_epoch
|| after.story != before.story
|| after.cell != before.cell
{
return None;
}
Some(ImeMutation {
document_uid: before.document_uid,
document_epoch: before.document_epoch,
story: before.story,
cell: before.cell,
anchor_before: before.anchor,
focus_before: before.focus,
anchor_after: after.anchor,
focus_after: after.focus,
text_len_before: before.text_len,
text_len_after: after.text_len,
deleted_before,
deleted_after,
inserted,
})
}
/// Clear an active pre-edit without inserting text; if no interaction snapshot exists, reset the tool.
fn end_preedit_without_typing(&mut self) -> Result<()> {
if !self.tool.composing() {
return Ok(());
}
match self.with_ctx(ViewInfo::default(), |s, cx| s.tool.ime_preedit(cx, "", None)) {
Some(actions) => self.run_actions(actions),
None => {
self.tool = designcraft_tools::create(self.tool.id());
Ok(())
}
}
}
/// Report false for no IME context or no-op ranges, and error when the selection is not on character boundaries.
fn ime_deletion_changes_text(&self, before: usize, after: usize, bytes: bool) -> Result<bool> {
let Some(context) = self.ime_text_context() else { return Ok(false) };
let Some((before_range, after_range)) = ime_delete_ranges(context.text, context.anchor, context.focus, before, after, bytes) else {
return Err(EngineError::Other("the IME text selection is not on character boundaries".into()));
};
Ok(!before_range.is_empty() || !after_range.is_empty())
}
}