Files
figcraft/crates/engine/src/canvas/textedit.rs
T

800 lines
34 KiB
Rust

//! On-canvas text editing: caret, selection, typing. A whole editing session is one undo step.
use std::sync::{LazyLock, Mutex};
use figcraft_doc::{Document, ListStyle, NodeId, NodeKind, TextData};
use crate::{EngineError, Preedit, Result, Session, TextEdit};
/// Most committed UTF-8 bytes an outgoing IME snapshot may describe.
///
/// This mirrors the fork's `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; the engine keeps its
/// own constant so this layer has no egui dependency. The query below returns only this bounded
/// window around the selection, never a copy of the whole document.
pub const MAX_IME_SURROUNDING_BYTES: usize = 4000;
/// Stable identity of the on-canvas text edit an IME context belongs to.
///
/// `document` and `node` do not change through ordinary text or caret edits. `editing_session`
/// changes when [`begin`] starts a new session for an equal `(document, node)` pair (leaving and
/// re-entering the same layer), so a replaced target never inherits an old composition.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ImeEditOwner {
/// The document tab's uid.
pub document: u64,
/// The edited text node.
pub node: NodeId,
/// [`TextEdit::editing_session`] of the current edit.
pub editing_session: u64,
}
/// Why the committed text around the IME caret cannot be described.
///
/// These are the only reasons this custom editor reports
/// `egui::output::IMESurroundingText::Unavailable`; an empty text layer is available with empty
/// text and a collapsed selection.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ImeSurroundingUnavailable {
/// No text layer is being edited.
NotEditing,
/// The editing target is not a text node any more.
NotText,
/// The selection alone is wider than [`MAX_IME_SURROUNDING_BYTES`], so no bounded snapshot
/// can contain both selection ends.
SelectionTooWide,
/// Defensive: the computed window was not a character-boundary slice. The rounding above
/// makes this unreachable for a well-formed text model.
InvalidWindow,
}
/// One bounded, committed logical-text observation for the outgoing IME snapshot.
///
/// `text` is never display/bidi order and never contains the pre-edit (which is UI-only in this
/// editor). `cursor` and `anchor` are UTF-8 byte offsets into `text`; `anchor > cursor` is a
/// valid reversed selection.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeSurrounding {
/// Identity of the edit this observation belongs to.
pub owner: ImeEditOwner,
/// At most [`MAX_IME_SURROUNDING_BYTES`] bytes of committed text around the selection.
pub text: String,
/// UTF-8 byte offset of the primary caret in [`Self::text`].
pub cursor: usize,
/// UTF-8 byte offset of the selection anchor in [`Self::text`].
pub anchor: usize,
}
/// Every editing session gets its own token; zero is valid and never reused deliberately.
///
/// A mutex (rather than an atomic) also works on wasm32 targets without 64-bit atomics.
static EDITING_SESSION: LazyLock<Mutex<u64>> = LazyLock::new(|| Mutex::new(0));
/// Return the next editing-session token from a mutex-protected counter (`wrapping_add`).
/// Zero is a valid token; the mutex also works on wasm32 without 64-bit atomics.
fn next_editing_session() -> u64 {
let mut session = match EDITING_SESSION.lock() {
Ok(session) => session,
Err(poisoned) => poisoned.into_inner(),
};
*session = session.wrapping_add(1);
*session
}
fn text_of(s: &Session, id: NodeId) -> Result<TextData> {
match &s.doc()?.get(id)?.kind {
NodeKind::Text(t) => Ok(t.clone()),
_ => Err(EngineError::Other(format!("{id} is not a text layer"))),
}
}
/// Start editing text node `id` with the caret at `caret` (None = select all).
pub fn begin(s: &mut Session, id: NodeId, caret: Option<usize>, created: bool) -> Result<()> {
let t = text_of(s, id)?;
let n = t.char_count();
s.begin_interaction("Edit text")?;
let ds = s.ds_mut()?;
// Editing the same layer again (a double-click into a text just made) keeps it new.
let created = created || ds.text_edit.as_ref().is_some_and(|te| te.id == id && te.created);
let (caret, anchor) = match caret {
Some(c) => (c.min(n), c.min(n)),
None => (n, 0),
};
ds.text_edit = Some(TextEdit { id, caret, anchor, created, editing_session: next_editing_session(), preedit: None });
ds.selection = vec![id];
ds.revision += 1;
s.live_text_notice(id);
Ok(())
}
/// Finish editing: an empty new text layer is removed; the session becomes one undo step.
pub fn end(s: &mut Session) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
s.ds_mut()?.text_edit = None;
// A widget's editable field: the typing is put back and the new value goes to the widget's
// plugin through `widget.setText`, one undo step (or none when nothing changed).
if let Some((w, field)) = s.doc().ok().and_then(|d| crate::widgets::field_of(d, te.id)) {
let value = text_of(s, te.id).map(|t| t.characters).unwrap_or_default();
s.cancel_interaction()?;
let ds = s.ds_mut()?;
ds.selection = vec![w];
ds.revision += 1;
if text_of(s, te.id).is_ok_and(|t| t.characters != value)
&& let Err(e) = s.execute("widget.setText", &serde_json::json!({"id": w, "field": field, "value": value}))
{
s.message = Some(e.to_string());
}
return Ok(());
}
// A board object's text (a sticky's note) stays when emptied; the object is selected again.
let owner = s.doc().ok().and_then(|d| crate::board::owner_of_text(d, te.id));
let empty = owner.is_none() && text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false);
if empty {
let doc = s.doc_mut()?;
let _ = doc.remove(te.id);
s.ds_mut()?.selection.clear();
}
if empty && te.created {
// Nothing to undo for a text box that was never filled. The gesture snapshot was taken
// after the layer was added, so it still holds it: re-base it on the document without the
// layer, and committing then records no step (and leaves the redo stack alone).
// A snapshot taken before the layer was added (the Text on a path tool, which also took
// the path layer away) is put back whole instead.
let ds = s.ds_mut()?;
let doc = ds.doc.clone();
if ds.interaction.as_ref().is_some_and(|it| it.doc.node(te.id).is_none()) {
s.cancel_interaction()?;
} else if let Some(it) = ds.interaction.as_mut() {
it.doc = doc;
}
}
s.commit_interaction()?;
let ds = s.ds_mut()?;
if let Some(o) = owner {
ds.selection = vec![o];
}
ds.revision += 1;
Ok(())
}
fn range(te: &TextEdit) -> (usize, usize) {
(te.caret.min(te.anchor), te.caret.max(te.anchor))
}
/// Apply an unconditional text-model mutation through `edit_if`, whose success clears the
/// composition, clamps caret and anchor to the character count, marks a `characters` override
/// when the node is source-backed, normalizes the document and bumps the revision.
fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> {
edit_if(s, |t, te| {
f(t, te);
true
})
.map(|_| ())
}
/// [`edit`] for a mutation that can still turn out to be a no-op.
///
/// The closure returns whether it changed the model; return `false` only before mutating (the
/// text is handed over directly). A no-op leaves the composition (`preedit`), the revision and the
/// document untouched, so an IME deletion that finds nothing to delete does not cancel an
/// in-progress composition.
fn edit_if(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit) -> bool) -> Result<bool> {
let ds = s.ds_mut()?;
let Some(mut te) = ds.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) };
let before = ds.doc.clone();
let doc = ds.doc_mut();
let n = doc.get_mut(te.id)?;
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) };
if !f(t, &mut te) {
return Ok(false);
}
// Any edit ends a composition (a commit replaces it with the final text).
te.preedit = None;
let len = t.char_count();
te.caret = te.caret.min(len);
te.anchor = te.anchor.min(len);
if n.source.is_some() {
n.overrides.insert("characters".into());
}
crate::normalize(doc, &before);
ds.text_edit = Some(te);
ds.revision += 1;
Ok(true)
}
/// What an accepted IME `Commit` did to the committed model.
///
/// An IME receipt may label the snapshot `InputMethod` only for [`Self::Plain`]. AutoFormat
/// (typing "- " at a line start turns the paragraph into a list) is an application
/// transformation in the same command, so the frame's change must be reported as `Other`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ImeInsertion {
/// Nothing was inserted (an empty, control-only, or bare-newline commit).
Nothing,
/// The committed text replaced the selection exactly.
Plain,
/// The editor transformed the commit before applying it: paragraph AutoFormat, or line-ending
/// normalisation/control-character removal. The frame's change is application-origin.
Transformed,
}
/// Type (or paste) `text` replacing the selection. Line endings are normalised and other control
/// characters dropped, so pasted plain text from any platform lands cleanly.
pub fn insert(s: &mut Session, text: &str) -> Result<()> {
insert_model(s, text, false).map(|_| ())
}
/// End the composition without changing the document, for an empty IME commit.
///
/// Returns without touching the session when no composition is active (or nothing is being
/// edited): an empty commit is otherwise a no-op, never an error.
fn clear_composition(s: &mut Session) -> Result<()> {
let composing = s.ds().ok().and_then(|d| d.text_edit.as_ref()).is_some_and(|te| te.preedit.is_some());
if !composing {
return Ok(());
}
let ds = s.ds_mut()?;
if let Some(te) = ds.text_edit.as_mut() {
te.preedit = None;
}
ds.revision += 1;
Ok(())
}
/// Apply an IME `Commit`, reporting whether it was a plain insertion.
///
/// The UI calls this instead of [`insert`] so only a commit that the editor applied as a plain
/// insertion can earn an `InputMethod` receipt. AutoFormat, line-ending normalisation and
/// control-character removal are application transformations and are reported as
/// [`ImeInsertion::Transformed`]. Like stock `TextEdit`, a bare `"\n"`/`"\r"` commit is ignored.
pub fn insert_ime_commit(s: &mut Session, text: &str) -> Result<ImeInsertion> {
insert_model(s, text, true)
}
/// Insert committed text, optionally as an IME commit: normalize line endings, drop control
/// characters except `\n`/`\t`, ignore a bare newline/CR IME commit, clear an active
/// composition for an empty or control-only IME commit, and turn a committed space after a
/// line-start `-`, `*` or `1.` into a bulleted/numbered list. Reports whether the insertion was
/// plain, transformed, or a no-op.
fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInsertion> {
// Stock `TextEdit` deliberately ignores these two commits (they are not a mutation); only a
// real commit may do so, since typing Enter goes through `insert`.
if ime_commit && matches!(text, "\n" | "\r") {
return Ok(ImeInsertion::Nothing);
}
let normalized: String =
crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
if normalized.is_empty() {
// An empty commit is how some integrations (Safari's egui web integration when the last
// composition character is deleted) end the composition. It inserts nothing, but it must
// still clear the pre-edit; stock `TextEdit` does the same in its non-ignored `Commit` arm.
if ime_commit {
clear_composition(s)?;
}
return Ok(ImeInsertion::Nothing);
}
// A commit the editor had to transform is an application edit, not a plain insertion.
let mut outcome = if normalized == text { ImeInsertion::Plain } else { ImeInsertion::Transformed };
let text = normalized;
edit(s, |t, te| {
let (a, b) = range(te);
// "- ", "* " or "1. " typed at the start of a plain paragraph starts a list.
if text == " " && a == b && t.para_list(t.para_of(a)).list == ListStyle::None {
let chars: Vec<char> = t.characters.chars().collect();
let start = line_start(&chars, a);
let typed: String = chars.get(start..a).unwrap_or(&[]).iter().collect();
let list = match typed.as_str() {
"-" | "*" => ListStyle::Bulleted,
"1." => ListStyle::Numbered,
_ => ListStyle::None,
};
if list != ListStyle::None {
t.replace_range(start, a, "");
t.set_para_lists(start, start, |pl| pl.list = list);
(te.caret, te.anchor) = (start, start);
outcome = ImeInsertion::Transformed;
return;
}
}
t.replace_range(a, b, &text);
te.caret = a + text.chars().count();
te.anchor = te.caret;
})?;
Ok(outcome)
}
/// List keys: Tab nests the selected list items a level deeper; Shift+Tab, Backspace at an item's
/// start and Enter on an empty item move it a level out (at the top: out of the list). Returns
/// whether the key was taken.
fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
let t = text_of(s, te.id)?;
let (a, b) = range(&te);
let chars: Vec<char> = t.characters.chars().collect();
let at_start = a == b && line_start(&chars, a) == a;
let item = t.para_list(t.para_of(a)).list != ListStyle::None;
let (pa, pb) = t.para_span(a, b);
let any_item = (pa..=pb).any(|p| t.para_list(p).list != ListStyle::None);
let deeper = match key {
"Tab" if any_item => !shift,
"Backspace" if item && at_start => false,
"Enter" if item && at_start && chars.get(a).is_none_or(|c| *c == '\n') => false,
_ => return Ok(false),
};
edit(s, |t, _| {
t.set_para_lists(a, b, |pl| match pl.list {
ListStyle::None => {}
_ if deeper => pl.indent = pl.indent.saturating_add(1),
_ if pl.indent > 0 => pl.indent -= 1,
_ => pl.list = ListStyle::None,
})
})?;
Ok(true)
}
/// Cmd/Ctrl+] and Cmd/Ctrl+[ while editing: the selected list items a level deeper or out, like
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list and items already
/// at the deepest level stay as they are).
pub fn list_indent(s: &mut Session, out: bool) -> Result<bool> {
let Some(id) = s.ds()?.text_edit.as_ref().map(|te| te.id) else { return Ok(false) };
let before = text_of(s, id)?;
if !list_key(s, "Tab", out)? {
return Ok(false);
}
Ok(text_of(s, id)? != before)
}
/// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word
/// ends where the script changes (Han, hiragana, katakana and Hangul runs are words of their own in
/// text without spaces). Combining marks and voicing marks belong to the char before them.
fn word_class(chars: &[char], mut i: usize) -> u8 {
while i > 0
&& chars.get(i).is_some_and(
|c| matches!(*c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0x3099..=0x309A | 0xFE20..=0xFE2F),
)
{
i -= 1;
}
match chars.get(i).copied() {
Some(
'\u{3005}'
| '\u{3006}'
| '\u{3007}'
| '\u{3400}'..='\u{4DBF}'
| '\u{4E00}'..='\u{9FFF}'
| '\u{F900}'..='\u{FAFF}'
| '\u{20000}'..='\u{3FFFF}',
) => 2,
Some('\u{3041}'..='\u{309F}') => 3,
Some('\u{30A0}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' | '\u{FF66}'..='\u{FF9F}') => 4,
Some('\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}') => 5,
Some(c) if c.is_alphanumeric() || c == '_' => 1,
_ => 0,
}
}
fn is_word(chars: &[char], i: usize) -> bool {
word_class(chars, i) != 0
}
/// The start of the word before `i` (skipping spaces and punctuation first).
fn word_left(chars: &[char], mut i: usize) -> usize {
while i > 0 && !is_word(chars, i - 1) {
i -= 1;
}
let class = word_class(chars, i.saturating_sub(1));
while i > 0 && word_class(chars, i - 1) == class && class != 0 {
i -= 1;
}
i
}
/// The end of the word after `i` (skipping spaces and punctuation first).
fn word_right(chars: &[char], mut i: usize) -> usize {
let n = chars.len();
while i < n && !is_word(chars, i) {
i += 1;
}
let class = word_class(chars, i);
while i < n && word_class(chars, i) == class && class != 0 {
i += 1;
}
i
}
/// A named key: Backspace, Delete, Left, Right, Up, Down, Home, End, Enter, Escape, SelectAll.
/// `word` moves or deletes by word, `line` to the line's ends (Up/Down, Home/End: the text's ends);
/// [`crate::keymap::text_motion`] gives them for the platform's keys. `ArrowLeft` etc. also work.
pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> Result<()> {
let key = key.strip_prefix("Arrow").unwrap_or(key);
let Some(te) = s.ds()?.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) };
let t = text_of(s, te.id)?;
let chars: Vec<char> = t.characters.chars().collect();
let n = chars.len();
if matches!(key, "Tab" | "Backspace" | "Enter") && !word && !line && list_key(s, key, shift)? {
return Ok(());
}
match key {
"Escape" => return end(s),
"Enter" => return insert(s, "\n"),
"Tab" => return insert(s, "\t"),
"Backspace" | "Delete" => {
return edit(s, |t, te| {
let (a, b) = range(te);
let (a, b) = if a != b {
(a, b)
} else if key == "Backspace" {
let from = if line {
line_start(&chars, a)
} else if word {
word_left(&chars, a)
} else {
figcraft_text::backspace_step(&t.characters, a)
};
(from, a)
} else {
let to = if line {
line_end(&chars, a)
} else if word {
word_right(&chars, a)
} else {
figcraft_text::grapheme_step(&t.characters, a, true).min(n)
};
(a, to)
};
t.replace_range(a, b, "");
te.caret = a;
te.anchor = a;
});
}
_ => {}
}
// Caret movement.
let layout = {
let doc = s.doc()?;
let node = doc.get(te.id)?;
figcraft_text::layout_node(node).map(|(l, _)| l)
};
let (a, b) = range(&te);
// Without Shift, a selection collapses first: plain arrows stop at its edge, word/line moves
// continue from that edge.
let cur = match key {
"Left" | "Up" | "Home" if !shift && a != b => a,
"Right" | "Down" | "End" if !shift && a != b => b,
_ => te.caret,
};
let next = match key {
"Left" if !shift && a != b && !word && !line => a,
"Right" if !shift && a != b && !word && !line => b,
"Left" if line => line_start(&chars, cur),
"Right" if line => line_end(&chars, cur),
"Left" if word => word_left(&chars, cur),
"Right" if word => word_right(&chars, cur),
// Plain arrows move on screen through right-to-left runs, by char elsewhere.
"Left" | "Right" => match layout.as_ref().and_then(|l| l.visual_step(cur, key == "Right")) {
Some(i) => i.min(n),
None => figcraft_text::grapheme_step(&t.characters, cur, key == "Right").min(n),
},
"Home" if line => 0,
"End" if line => n,
"Home" => line_start(&chars, cur),
"End" => line_end(&chars, cur),
"Up" | "Down" => match &layout {
_ if line => {
if key == "Up" {
0
} else {
n
}
}
Some(l) => {
let (x, li) = l.caret(cur);
let target = if key == "Up" { li.checked_sub(1) } else { Some(li + 1).filter(|k| *k < l.lines.len()) };
match target.and_then(|k| l.lines.get(k)) {
Some(line) => l.hit(x, line.top + line.height / 2.0),
None => {
if key == "Up" {
0
} else {
n
}
}
}
}
None => cur,
},
"SelectAll" => return select(s, 0, n),
_ => return Ok(()),
};
let anchor = if shift { te.anchor } else { next };
select(s, anchor, next)
}
/// Set the selection (`anchor`..`caret`, clamped to the text). Moving the caret abandons any
/// composition in progress.
fn select(s: &mut Session, anchor: usize, caret: usize) -> Result<()> {
let Some(id) = s.ds()?.text_edit.as_ref().map(|te| te.id) else { return Ok(()) };
let len = s.doc()?.node(id).and_then(|n| n.kind.text()).map_or(0, TextData::char_count);
let ds = s.ds_mut()?;
if let Some(te) = ds.text_edit.as_mut() {
te.anchor = anchor.min(len);
te.caret = caret.min(len);
te.preedit = None;
}
ds.revision += 1;
Ok(())
}
/// Delete the text surrounding the caret/selection that an IME reports with
/// [`egui::ImeEvent::DeleteSurroundingBytes`]: `before_bytes` UTF-8 bytes end where the selection
/// starts and `after_bytes` start where it ends. As in stock `TextEdit`, the selected text itself
/// is kept for the paired `Commit`; only whole characters covered by the byte counts beyond the
/// selection are removed (a character the range only partly covers stays). Returns whether
/// anything was deleted.
pub fn delete_surrounding_bytes(s: &mut Session, before_bytes: usize, after_bytes: usize) -> Result<bool> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
let t = text_of(s, te.id)?;
let (a, b) = range(&te);
let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes);
if before == 0 && after == 0 {
return Ok(false);
}
edit_if(s, |t, te| {
let (a, b) = range(te);
let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes);
if before == 0 && after == 0 {
return false;
}
// Delete after the selection first so the before range's indices stay valid.
if after > 0 {
t.replace_range(b, b + after, "");
}
if before > 0 {
t.replace_range(a - before, a, "");
}
// Keep the selection: both ends move left by the deleted prefix, reversed or not.
te.caret = te.caret.saturating_sub(before);
te.anchor = te.anchor.saturating_sub(before);
true
})
}
/// Delete surrounding text counted in characters (the older `DeleteSurrounding` event). The
/// selection is kept, as in stock `TextEdit`; only text before and after it is removed.
pub fn delete_surrounding_chars(s: &mut Session, before_chars: usize, after_chars: usize) -> Result<bool> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
let t = text_of(s, te.id)?;
let (a, b) = range(&te);
let (_, b_byte) = char_byte_offsets(&t.characters, a, b);
let before = before_chars.min(a);
let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count());
if before == 0 && after == 0 {
return Ok(false);
}
edit_if(s, |t, te| {
let (a, b) = range(te);
let (_, b_byte) = char_byte_offsets(&t.characters, a, b);
let before = before_chars.min(a);
let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count());
if before == 0 && after == 0 {
return false;
}
if after > 0 {
t.replace_range(b, b + after, "");
}
if before > 0 {
t.replace_range(a - before, a, "");
}
te.caret = te.caret.saturating_sub(before);
te.anchor = te.anchor.saturating_sub(before);
true
})
}
/// The UTF-8 byte offsets of character indices `a` and `b` in one forward pass.
///
/// An index at or past the end maps to `text.len()`. Walking once (instead of twice) matters on
/// every IME event over a large text layer; the walk is still O(max(a, b)) characters, and callers
/// that need the end still pay O(n). See [`ime_surrounding`].
fn char_byte_offsets(text: &str, a: usize, b: usize) -> (usize, usize) {
let (mut a_byte, mut b_byte) = (None, None);
for (i, (byte, _)) in text.char_indices().enumerate() {
if a_byte.is_none() && i == a {
a_byte = Some(byte);
}
if b_byte.is_none() && i == b {
b_byte = Some(byte);
}
if a_byte.is_some() && b_byte.is_some() {
break;
}
}
(a_byte.unwrap_or(text.len()), b_byte.unwrap_or(text.len()))
}
/// `(before, after)` characters covered by the IME's byte counts around the selection `[a, b)`
/// (character indices): mirror of egui's own `surrounding_bytes_to_chars`.
fn surrounding_chars(text: &str, a: usize, b: usize, before_bytes: usize, after_bytes: usize) -> (usize, usize) {
let (a_byte, b_byte) = char_byte_offsets(text, a, b);
let from = a_byte.saturating_sub(before_bytes);
let before = text[..a_byte].char_indices().rev().take_while(|(i, _)| *i >= from).count();
let after = text[b_byte..].char_indices().take_while(|(i, c)| i + c.len_utf8() <= after_bytes).count();
(before, after)
}
/// The committed text around the IME caret for the outgoing surrounding-text snapshot.
///
/// This is the edited node's logical model text (never the bidi display order), with the
/// pre-edit excluded because a composition is UI-only in this editor until it is committed.
/// Character caret/anchor indices become independent UTF-8 byte offsets, so a reversed
/// selection stays reversed. At most [`MAX_IME_SURROUNDING_BYTES`] bytes are copied: the window
/// is centred on the selection and rounded inward to character boundaries. An empty text layer
/// is `Ok` with `text == ""` and both offsets zero; the result is [`ImeSurroundingUnavailable`]
/// only when there is no text edit, the target is not a text node, or the selection alone is
/// wider than the budget.
///
/// Cost: one character walk up to the later of the two cursor indices (to turn char indices into
/// UTF-8 bytes) plus the bounded copy. The caret is a character index, so reaching a caret near
/// the end of a very large layer is still O(n); the UI calls this once per pass and twice around
/// each text-affecting event (`ime_stamp`), so such a layer pays O(caret/anchor) per event. The
/// document is never cloned here.
pub fn ime_surrounding(s: &Session) -> std::result::Result<ImeSurrounding, ImeSurroundingUnavailable> {
let ds = s.ds().map_err(|_| ImeSurroundingUnavailable::NotEditing)?;
let te = ds.text_edit.as_ref().ok_or(ImeSurroundingUnavailable::NotEditing)?;
let node = ds.doc.node(te.id).ok_or(ImeSurroundingUnavailable::NotText)?;
let NodeKind::Text(t) = &node.kind else { return Err(ImeSurroundingUnavailable::NotText) };
let text = t.characters.as_str();
// One pass for both offsets (the caret and anchor are already clamped by `edit`/`select`).
let (cursor, anchor) = char_byte_offsets(text, te.caret, te.anchor);
let (selection_start, selection_end) = (cursor.min(anchor), cursor.max(anchor));
let selection_len = selection_end - selection_start;
if selection_len > MAX_IME_SURROUNDING_BYTES {
return Err(ImeSurroundingUnavailable::SelectionTooWide);
}
// Spend the budget on both sides, giving room unused on one side to the other.
let budget = MAX_IME_SURROUNDING_BYTES - selection_len;
let before_available = selection_start;
let after_available = text.len() - selection_end;
let mut before = (budget / 2).min(before_available);
let mut after = (budget - before).min(after_available);
before = (budget - after).min(before_available);
after = (budget - before).min(after_available);
let mut window_start = selection_start - before;
let mut window_end = selection_end + after;
// Round inward so the window stays within the byte budget and on character boundaries.
while window_start < window_end && !text.is_char_boundary(window_start) {
window_start += 1;
}
while window_end > window_start && !text.is_char_boundary(window_end) {
window_end -= 1;
}
let Some(window) = text.get(window_start..window_end) else {
return Err(ImeSurroundingUnavailable::InvalidWindow);
};
Ok(ImeSurrounding {
owner: ImeEditOwner { document: ds.uid, node: te.id, editing_session: te.editing_session },
text: window.to_owned(),
cursor: cursor.saturating_sub(window_start),
anchor: anchor.saturating_sub(window_start),
})
}
/// Show an IME composition inline at the caret (`cursor` = the IME's cursor inside it, in chars;
/// default its end). The document doesn't change until the composition is committed with
/// [`insert`]; an empty `text` cancels it. Starting to compose over a selection deletes it, as
/// typing would.
pub fn preedit(s: &mut Session, text: &str, cursor: Option<usize>) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) };
let text: String = text.chars().filter(|c| !c.is_control()).take(MAX_PREEDIT).collect();
if text.is_empty() && te.preedit.is_none() {
return Ok(()); // IMEs send empty compositions when idle
}
if !text.is_empty() && te.preedit.is_none() && te.caret != te.anchor {
edit(s, |t, te| {
let (a, b) = range(te);
t.replace_range(a, b, "");
te.caret = a;
te.anchor = a;
})?;
}
let n = text.chars().count();
let ds = s.ds_mut()?;
if let Some(te) = ds.text_edit.as_mut() {
te.preedit = (n > 0).then(|| Preedit { text, cursor: cursor.unwrap_or(n).min(n) });
}
ds.revision += 1;
Ok(())
}
/// Longest composition shown (IMEs compose a phrase at a time).
const MAX_PREEDIT: usize = 1000;
/// The document as it looks while composing: the composition inserted at the caret, with text
/// auto-size and layout applied. Also returns the composition's char range and the IME cursor's
/// index in that document. `None` when nothing is being composed.
pub fn preedit_doc(s: &Session) -> Option<(Document, (usize, usize), usize)> {
let ds = s.ds().ok()?;
let te = ds.text_edit.as_ref()?;
let pe = te.preedit.as_ref()?;
let mut doc = (*ds.doc).clone();
let NodeKind::Text(t) = &mut doc.get_mut(te.id).ok()?.kind else { return None };
let at = te.caret.min(t.char_count());
t.replace_range(at, at, &pe.text);
crate::normalize(&mut doc, &ds.doc);
let n = pe.text.chars().count();
Some((doc, (at, at + n), at + pe.cursor.min(n)))
}
fn line_start(chars: &[char], mut i: usize) -> usize {
while i > 0 && chars.get(i - 1).is_some_and(|c| *c != '\n') {
i -= 1;
}
i
}
fn line_end(chars: &[char], mut i: usize) -> usize {
while i < chars.len() && chars.get(i).is_some_and(|c| *c != '\n') {
i += 1;
}
i
}
/// Place the caret (and optionally extend the selection) at char `index`.
pub fn set_caret(s: &mut Session, index: usize, extend: bool) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
select(s, if extend { te.anchor } else { index }, index)
}
/// Select the word at `index` (double-click).
pub fn select_word(s: &mut Session, index: usize) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
let chars: Vec<char> = text_of(s, te.id)?.characters.chars().collect();
let mut a = index.min(chars.len());
// Double-clicking just after a word's last char (or at the end) selects that word.
if a > 0 && !is_word(&chars, a) && is_word(&chars, a - 1) {
a -= 1;
}
let mut b = a;
// Words by script (see `word_class`), then runs of spaces (10) and of punctuation (11).
let class = |i: usize| match word_class(&chars, i) {
0 if chars.get(i).is_some_and(|c| c.is_whitespace()) => 10,
0 => 11,
k => k,
};
// A word, a run of spaces or a run of punctuation, like the incumbent; newlines stay unselected.
if let Some(k) = chars.get(a).filter(|c| **c != '\n').map(|_| class(a)) {
while a > 0 && chars.get(a - 1).is_some_and(|c| *c != '\n') && class(a - 1) == k {
a -= 1;
}
while b < chars.len() && chars.get(b).is_some_and(|c| *c != '\n') && class(b) == k {
b += 1;
}
}
select(s, a, b)
}
/// Select the line (paragraph) containing `index` (triple-click).
pub fn select_line(s: &mut Session, index: usize) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
let chars: Vec<char> = text_of(s, te.id)?.characters.chars().collect();
let i = index.min(chars.len());
select(s, line_start(&chars, i), line_end(&chars, i))
}
/// The selected text while editing (for copy).
pub fn selected_text(s: &Session) -> Option<String> {
let (id, a, b) = selected_range(s)?;
let doc = s.doc().ok()?;
let t = doc.node(id)?.kind.text()?;
Some(t.characters.chars().skip(a).take(b.saturating_sub(a)).collect())
}
/// The selected range while editing (for styling commands), or None.
pub fn selected_range(s: &Session) -> Option<(NodeId, usize, usize)> {
let te = s.ds().ok()?.text_edit.clone()?;
let (a, b) = range(&te);
Some((te.id, a, b))
}
#[cfg(test)]
mod tests;