#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct Buffer {
pub text: String,
pub compose: Option<(usize, usize)>,
}
#[derive(Debug, Default)]
pub struct TextInputBridge {
settled: String,
composing: String,
awaiting_clear: Option<u32>,
}
pub const REFOCUS_PATIENCE: u32 = 30;
impl TextInputBridge {
pub fn translate(&mut self, buffer: &Buffer) -> Vec<egui::Event> {
if let Some(left) = self.awaiting_clear {
if buffer.text.is_empty() {
self.awaiting_clear = None;
return Vec::new();
}
if left > 0 {
self.awaiting_clear = Some(left - 1);
return Vec::new();
}
self.awaiting_clear = None;
self.adopt(buffer);
return Vec::new();
}
let region = buffer
.compose
.and_then(|(a, b)| byte_range(&buffer.text, a.min(b), a.max(b)))
.filter(|r| !r.is_empty());
let (settled, composing) = match ®ion {
Some(r) => (&buffer.text[..r.start], &buffer.text[r.start..r.end]),
None => (buffer.text.as_str(), ""),
};
let mut events = Vec::new();
let common = common_prefix(&self.settled, settled);
let removed = self.settled[common..].chars().count();
for _ in 0..removed {
events.push(backspace(true));
events.push(backspace(false));
}
let added = &settled[common..];
let was_composing = !self.composing.is_empty();
if !added.is_empty() {
events.push(if was_composing {
egui::Event::Ime(egui::ImeEvent::Commit(added.to_owned()))
} else {
egui::Event::Text(added.to_owned())
});
}
if composing != self.composing {
if !(composing.is_empty() && !added.is_empty()) {
events.push(egui::Event::Ime(egui::ImeEvent::Preedit {
text: composing.to_owned(),
active_range_chars: None,
}));
}
}
self.settled = settled.to_owned();
self.composing = composing.to_owned();
events
}
pub fn refocus(&mut self) {
self.settled.clear();
self.composing.clear();
self.awaiting_clear = Some(REFOCUS_PATIENCE - 1);
}
fn adopt(&mut self, buffer: &Buffer) {
let region = buffer
.compose
.and_then(|(a, b)| byte_range(&buffer.text, a.min(b), a.max(b)))
.filter(|r| !r.is_empty());
match region {
Some(r) => {
self.settled = buffer.text[..r.start].to_owned();
self.composing = buffer.text[r.start..r.end].to_owned();
}
None => {
self.settled = buffer.text.clone();
self.composing.clear();
}
}
}
}
fn backspace(pressed: bool) -> egui::Event {
egui::Event::Key {
key: egui::Key::Backspace,
physical_key: None,
pressed,
repeat: false,
modifiers: egui::Modifiers::NONE,
}
}
fn common_prefix(a: &str, b: &str) -> usize {
a.char_indices()
.zip(b.chars())
.find(|((_, x), y)| x != y)
.map(|((i, _), _)| i)
.unwrap_or_else(|| a.len().min(b.len()))
}
fn byte_range(text: &str, start: usize, end: usize) -> Option<std::ops::Range<usize>> {
let mut units = 0;
let mut from = None;
let mut to = None;
for (byte, ch) in text.char_indices() {
if units == start {
from = Some(byte);
}
if units == end {
to = Some(byte);
}
units += ch.len_utf16();
}
if units == start {
from = Some(text.len());
}
if units == end {
to = Some(text.len());
}
Some(from?..to?)
}
#[cfg(test)]
mod tests {
use super::*;
fn buf(text: &str, compose: Option<(usize, usize)>) -> Buffer {
Buffer {
text: text.into(),
compose,
}
}
fn text(s: &str) -> egui::Event {
egui::Event::Text(s.into())
}
fn preedit(s: &str) -> egui::Event {
egui::Event::Ime(egui::ImeEvent::Preedit {
text: s.into(),
active_range_chars: None,
})
}
fn commit(s: &str) -> egui::Event {
egui::Event::Ime(egui::ImeEvent::Commit(s.into()))
}
#[test]
fn typing_gives_only_what_is_new() {
let mut bridge = TextInputBridge::default();
assert_eq!(bridge.translate(&buf("sapph", None)), vec![text("sapph")]);
assert_eq!(
bridge.translate(&buf("sapphire:", None)),
vec![text("ire:")]
);
assert!(bridge.translate(&buf("sapphire:", None)).is_empty());
}
#[test]
fn a_long_paste_arrives_whole() {
let mut bridge = TextInputBridge::default();
let ticket = format!("sapphire:{}", "X".repeat(300));
assert_eq!(
bridge.translate(&buf(&ticket[..150], None)),
vec![text(&ticket[..150])]
);
assert_eq!(
bridge.translate(&buf(&ticket, None)),
vec![text(&ticket[150..])]
);
}
#[test]
fn deleted_characters_are_backspaces() {
let mut bridge = TextInputBridge::default();
bridge.translate(&buf("日本語", None));
assert_eq!(
bridge.translate(&buf("日本", None)),
vec![backspace(true), backspace(false)]
);
assert_eq!(
bridge.translate(&buf("日a", None)),
vec![backspace(true), backspace(false), text("a")]
);
}
#[test]
fn a_japanese_composition_is_a_preedit_then_a_commit() {
let mut bridge = TextInputBridge::default();
assert_eq!(
bridge.translate(&buf("に", Some((0, 1)))),
vec![preedit("に")]
);
assert_eq!(
bridge.translate(&buf("にほん", Some((0, 3)))),
vec![preedit("にほん")]
);
assert_eq!(bridge.translate(&buf("日本", None)), vec![commit("日本")]);
}
#[test]
fn a_composition_after_settled_text_keeps_the_settled_text() {
let mut bridge = TextInputBridge::default();
bridge.translate(&buf("日本", None));
assert_eq!(
bridge.translate(&buf("日本ご", Some((2, 3)))),
vec![preedit("ご")]
);
assert_eq!(bridge.translate(&buf("日本語", None)), vec![commit("語")]);
}
#[test]
fn an_abandoned_composition_clears_the_preedit() {
let mut bridge = TextInputBridge::default();
bridge.translate(&buf("か", Some((0, 1))));
assert_eq!(bridge.translate(&buf("", None)), vec![preedit("")]);
}
#[test]
fn refocus_forgets_the_old_field() {
let mut bridge = TextInputBridge::default();
bridge.translate(&buf("ticket", None));
bridge.refocus();
assert!(bridge.translate(&buf("ticket", None)).is_empty());
assert!(bridge.translate(&buf("ticket", None)).is_empty());
assert!(bridge.translate(&buf("", None)).is_empty());
assert_eq!(bridge.translate(&buf("p", None)), vec![text("p")]);
}
#[test]
fn a_clear_that_never_lands_is_given_up_without_retyping() {
let mut bridge = TextInputBridge::default();
bridge.translate(&buf("ticket", None));
bridge.refocus();
for _ in 0..REFOCUS_PATIENCE {
assert!(bridge.translate(&buf("ticket", None)).is_empty());
}
assert_eq!(bridge.translate(&buf("ticketx", None)), vec![text("x")]);
}
#[test]
fn spans_count_utf16_units_not_bytes() {
assert_eq!(byte_range("a😀b", 1, 3), Some(1..5));
assert_eq!(byte_range("a😀b", 2, 3), None, "inside a surrogate pair");
assert_eq!(byte_range("ab", 0, 5), None, "past the end");
}
#[test]
fn the_common_prefix_stops_on_a_character_boundary() {
assert_eq!(common_prefix("日本語", "日本人"), "日本".len());
assert_eq!(common_prefix("abc", "ab"), 2);
assert_eq!(common_prefix("", "x"), 0);
}
}