///|
pub(all) enum TextInputSessionAction {
NoImeChange
EnableIme(@window_core.ImeRequest)
UpdateIme(@window_core.ImeRequest)
DisableIme(@window_core.ImeRequest)
} derive(Eq, Debug)
///|
pub extend TextInputSessionAction with Debug::{to_repr}
///|
pub extend TextInputSessionAction with Eq::{not_equal, equal}
///|
pub(all) struct TextInputImeRequestDiagnostics {
focused : Bool
text_length : Int
text_utf8_length : Int
cursor : Int
anchor : Int
cursor_utf8 : Int
anchor_utf8 : Int
cursor_is_grapheme_boundary : Bool
anchor_is_grapheme_boundary : Bool
candidate_anchor : @core.Rect
has_surrounding_text : Bool
surrounding_text_utf8_length : Int
// The text the IME request delivers as surrounding text. `None` on the
// exact path, where the whole input text already fits the surrounding
// text budget; oversized texts deliver a bounded window around the
// cursor instead (see `text_input_ime_request_diagnostics_windowed`).
surrounding_text : String?
} derive(Eq, Debug, ToJson)
///|
pub extend TextInputImeRequestDiagnostics with Debug::{to_repr}
///|
pub extend TextInputImeRequestDiagnostics with Eq::{not_equal, equal}
///|
pub extend TextInputImeRequestDiagnostics with ToJson::{to_json}
///|
/// Identity of the text-input state the cached IME diagnostics describe.
/// The text is compared by object identity (`physical_equal`), never by
/// value: the runtime's input text is an immutable string, so an
/// unchanged frame re-observes the same object while every edit
/// materializes a new one. A content comparison here would itself scan
/// megabytes on every frame.
pub struct TextInputImeStateKey {
focused : Bool
text : String
caret : Int
selection : @core.TextRange?
composition : String?
composition_cursor : @core.TextRange?
caret_rect : @core.Rect
}
///|
fn TextInputImeStateKey::matches(
self : TextInputImeStateKey,
other : TextInputImeStateKey,
) -> Bool {
self.focused == other.focused &&
physical_equal(self.text, other.text) &&
self.caret == other.caret &&
self.selection == other.selection &&
self.composition == other.composition &&
self.composition_cursor == other.composition_cursor &&
self.caret_rect == other.caret_rect
}
///|
fn text_input_ime_state_key(
state : @core.TextInputState,
) -> TextInputImeStateKey {
{
focused: state.focused,
text: state.text,
caret: state.caret,
selection: state.selection,
composition: state.composition,
composition_cursor: state.composition_cursor,
caret_rect: state.caret_rect,
}
}
///|
pub(all) struct TextInputSession {
mut enabled : Bool
mut last_request : @window_core.ImeRequestData?
mut last_capabilities : @window_core.ImeCapabilities?
mut last_diagnostics : TextInputImeRequestDiagnostics?
mut last_state : TextInputImeStateKey?
}
///|
pub fn TextInputSession::new() -> TextInputSession {
{
enabled: false,
last_request: None,
last_capabilities: None,
last_diagnostics: None,
last_state: None,
}
}
///|
/// Diagnostics for `state`, reused from the previous sync while the
/// focused text input is unchanged. The window redraw loop re-syncs on
/// every presented frame; without this reuse every frame paid the full
/// grapheme/UTF-8 diagnostics even though nothing had changed. Recompute
/// is still bounded for oversized texts (see
/// `text_input_ime_request_diagnostics_windowed`), so an edit on a
/// multi-megabyte input never rebuilds a whole-document boundary table.
fn TextInputSession::diagnostics_for(
self : TextInputSession,
state : @core.TextInputState,
) -> TextInputImeRequestDiagnostics {
let current = text_input_ime_state_key(state)
match (self.last_state, self.last_diagnostics) {
(Some(cached), Some(diagnostics)) if cached.matches(current) => diagnostics
_ => {
let diagnostics = text_input_ime_request_diagnostics(state)
self.last_state = Some(current)
self.last_diagnostics = Some(diagnostics)
diagnostics
}
}
}
///|
pub fn TextInputSession::sync(
self : TextInputSession,
state : @core.TextInputState,
) -> TextInputSessionAction {
if !state.focused {
if self.enabled {
self.enabled = false
self.clear_cached_request()
DisableIme(Disable)
} else {
self.clear_cached_request()
NoImeChange
}
} else {
let diagnostics = self.diagnostics_for(state)
let update = text_input_ime_request_data_with_diagnostics(
state, diagnostics,
)
let capabilities = text_input_ime_capabilities(update)
if self.enabled {
if self.has_cached_request(update, capabilities) {
NoImeChange
} else {
self.cache_request(update, capabilities, diagnostics)
UpdateIme(update.into_request())
}
} else {
match
@window_core.ImeEnableRequest::from_capabilities(capabilities, update) {
Some(request) => {
self.enabled = true
self.cache_request(update, capabilities, diagnostics)
EnableIme(request.into_request())
}
None => {
self.clear_cached_request()
NoImeChange
}
}
}
}
}
///|
fn TextInputSession::has_cached_request(
self : TextInputSession,
request : @window_core.ImeRequestData,
capabilities : @window_core.ImeCapabilities,
) -> Bool {
self.last_request == Some(request) &&
self.last_capabilities == Some(capabilities)
}
///|
fn TextInputSession::cache_request(
self : TextInputSession,
request : @window_core.ImeRequestData,
capabilities : @window_core.ImeCapabilities,
diagnostics : TextInputImeRequestDiagnostics,
) -> Unit {
self.last_request = Some(request)
self.last_capabilities = Some(capabilities)
self.last_diagnostics = Some(diagnostics)
}
///|
fn TextInputSession::clear_cached_request(self : TextInputSession) -> Unit {
self.last_request = None
self.last_capabilities = None
self.last_diagnostics = None
self.last_state = None
}
///|
pub fn TextInputSession::last_diagnostics(
self : TextInputSession,
) -> TextInputImeRequestDiagnostics? {
self.last_diagnostics
}
///|
pub fn text_input_ime_request_data(
state : @core.TextInputState,
) -> @window_core.ImeRequestData {
text_input_ime_request_data_with_diagnostics(
state,
text_input_ime_request_diagnostics(state),
)
}
///|
pub fn text_input_ime_request_diagnostics(
state : @core.TextInputState,
) -> TextInputImeRequestDiagnostics {
// UTF-8 byte length is at least the UTF-16 code-unit count, so a text
// beyond the surrounding-text budget can never be delivered in full.
// Whole-document diagnostics would build a per-codepoint boundary table
// and re-encode multi-megabyte prefixes per frame; oversized texts get
// caret-local grapheme context and a truncated surrounding-text window
// instead.
if state.text.length() > @window_core.ImeSurroundingText::max_text_bytes() {
return text_input_ime_request_diagnostics_windowed(state)
}
let boundaries = @core.TextGraphemeBoundaries::new(text=state.text)
let cursor = boundaries.nearest_boundary(state.caret)
let anchor = boundaries.nearest_boundary(text_input_selection_anchor(state))
let text_length = boundaries.length()
let text_utf8_length = boundaries.utf8_offset(text_length)
{
focused: state.focused,
text_length,
text_utf8_length,
cursor,
anchor,
cursor_utf8: boundaries.nearest_boundary_utf8_offset(cursor),
anchor_utf8: boundaries.nearest_boundary_utf8_offset(anchor),
cursor_is_grapheme_boundary: boundaries.is_boundary(cursor),
anchor_is_grapheme_boundary: boundaries.is_boundary(anchor),
candidate_anchor: text_input_candidate_anchor_rect(state),
has_surrounding_text: text_utf8_length <=
@window_core.ImeSurroundingText::max_text_bytes(),
surrounding_text_utf8_length: text_utf8_length,
surrounding_text: None,
}
}
///|
/// Half-width of the oversized-text diagnostics window, in codepoints.
/// Every practical grapheme cluster (emoji ZWJ chains, regional-indicator
/// runs, Indic conjuncts) fits far inside this radius, so boundaries at
/// the caret and anchor match the whole-text table.
const TEXT_INPUT_IME_WINDOW_CHARS : Int = 128
///|
/// A bounded, grapheme-snapped slice of the input text around one offset.
/// `boundaries` index codepoints relative to `start`; `utf8` offsets
/// computed from them index `text` bytes.
priv struct TextInputImeWindow {
text : String
boundaries : @core.TextGraphemeBoundaries
start : Int
utf8_length : Int
}
///|
/// Build the diagnostics window for `center` (a codepoint index). The
/// window edges are located with one scan that stops at the window end,
/// so carets near the document start never walk the whole text; the
/// slice itself is at most a few hundred bytes.
fn text_input_ime_window(text : String, center : Int) -> TextInputImeWindow {
let start_char = (center - TEXT_INPUT_IME_WINDOW_CHARS).max(0)
let end_char = center + TEXT_INPUT_IME_WINDOW_CHARS
let mut char_index = 0
let mut unit_offset = 0
let mut start_unit = 0
let mut start_found = start_char == 0
let mut end_unit = 0
let mut end_found = false
for ch in text {
if !start_found && char_index == start_char {
start_unit = unit_offset
start_found = true
}
if char_index >= end_char {
end_unit = unit_offset
end_found = true
break
}
unit_offset = unit_offset + ch.utf16_len()
char_index = char_index + 1
}
if !end_found {
end_unit = unit_offset
}
let text = if start_found {
text.exact_view(start=start_unit, end=end_unit).to_owned()
} else {
// Defensive: the center is at or past the text end (carets are
// clamped by the runtime), so the window collapses to an empty slice.
""
}
let boundaries = @core.TextGraphemeBoundaries::new(text~)
let utf8_length = boundaries.utf8_offset(boundaries.length())
{
text,
boundaries,
start: if start_found {
start_char
} else {
char_index
},
utf8_length,
}
}
///|
/// Nearest grapheme boundary to `offset` (a text-relative codepoint
/// index) inside `window`; returns the text-relative position.
fn text_input_ime_window_boundary(
window : TextInputImeWindow,
offset : Int,
) -> Int {
let window_local = (offset - window.start)
.max(0)
.min(window.boundaries.length())
window.start + window.boundaries.nearest_boundary(window_local)
}
///|
fn text_input_ime_request_diagnostics_windowed(
state : @core.TextInputState,
) -> TextInputImeRequestDiagnostics {
let anchor = text_input_selection_anchor(state)
let caret_window = text_input_ime_window(state.text, state.caret)
// The anchor shares the caret window while it stays inside it (the
// typing case); a distant selection anchor gets its own window.
let anchor_window = if anchor >= caret_window.start &&
anchor <= caret_window.start + caret_window.boundaries.length() {
caret_window
} else {
text_input_ime_window(state.text, anchor)
}
let cursor = text_input_ime_window_boundary(caret_window, state.caret)
let anchor_position = text_input_ime_window_boundary(anchor_window, anchor)
let cursor_window_offset = cursor - caret_window.start
let cursor_utf8 = caret_window.boundaries.utf8_offset(cursor_window_offset)
let anchor_window_offset = anchor_position - anchor_window.start
let anchor_utf8 = anchor_window.boundaries.utf8_offset(anchor_window_offset)
{
focused: state.focused,
// Oversized texts report the UTF-16 code-unit count here; the exact
// codepoint count would cost an O(document) decode and nothing on
// this path reads it.
text_length: state.text.length(),
text_utf8_length: 0,
cursor,
anchor: anchor_position,
cursor_utf8,
anchor_utf8,
cursor_is_grapheme_boundary: caret_window.boundaries.is_boundary(
cursor_window_offset,
),
anchor_is_grapheme_boundary: anchor_window.boundaries.is_boundary(
anchor_window_offset,
),
candidate_anchor: text_input_candidate_anchor_rect(state),
has_surrounding_text: caret_window.utf8_length <=
@window_core.ImeSurroundingText::max_text_bytes(),
surrounding_text_utf8_length: caret_window.utf8_length,
surrounding_text: Some(caret_window.text),
}
}
///|
fn text_input_ime_request_data_with_diagnostics(
state : @core.TextInputState,
diagnostics : TextInputImeRequestDiagnostics,
) -> @window_core.ImeRequestData {
let mut request = @window_core.ImeRequestData::default()
.with_hint_and_purpose(@window_core.ImeHint::none(), Normal)
.with_cursor_area(
@dpi.LogicalPosition::LogicalPosition(
diagnostics.candidate_anchor.origin.x,
diagnostics.candidate_anchor.origin.y,
).into_position(),
@dpi.LogicalSize::LogicalSize(
diagnostics.candidate_anchor.size.width,
diagnostics.candidate_anchor.size.height,
).into_size(),
)
match text_input_surrounding_text(state, diagnostics) {
Some(surrounding) => request = request.with_surrounding_text(surrounding)
None => ()
}
request
}
///|
fn text_input_ime_capabilities(
request_data : @window_core.ImeRequestData,
) -> @window_core.ImeCapabilities {
let mut capabilities = @window_core.ImeCapabilities::default()
.with_hint_and_purpose()
.with_cursor_area()
if request_data.has_surrounding_text() {
capabilities = capabilities.with_surrounding_text()
}
capabilities
}
///|
fn text_input_surrounding_text(
state : @core.TextInputState,
diagnostics : TextInputImeRequestDiagnostics,
) -> @window_core.ImeSurroundingText? {
// Oversized texts deliver the truncated window the diagnostics built;
// its UTF-8 offsets already index the window.
let text = match diagnostics.surrounding_text {
Some(window) => window
None => state.text
}
Some(
ImeSurroundingText(text, diagnostics.cursor_utf8, diagnostics.anchor_utf8),
) catch {
_ => None
}
}
///|
fn text_input_selection_anchor(state : @core.TextInputState) -> Int {
match state.selection {
Some(selection) =>
if state.caret == selection.start {
selection.end
} else {
selection.start
}
None => state.caret
}
}
///|
fn max_double(a : Double, b : Double) -> Double {
if a > b {
a
} else {
b
}
}
///|
fn text_input_candidate_anchor_rect(state : @core.TextInputState) -> @core.Rect {
@core.Rect::new(
x=state.caret_rect.origin.x,
y=state.caret_rect.origin.y,
width=max_double(1.0, state.caret_rect.size.width),
height=max_double(1.0, state.caret_rect.size.height),
)
}