use frus_widgets::Edit;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct Element {
pub value: String,
pub start: u32,
pub end: u32,
pub backward: bool,
pub composing: Option<String>,
}
pub(crate) fn char_index(text: &str, utf16: u32) -> usize {
let mut units = 0;
for (index, c) in text.chars().enumerate() {
units += c.len_utf16() as u32;
if units > utf16 {
return index;
}
}
text.chars().count()
}
pub(crate) fn read(element: &Element) -> (String, Edit) {
let text = &element.value;
let start = char_index(text, element.start.min(element.end));
let end = char_index(text, element.start.max(element.end));
let (cursor, anchor) = if element.backward {
(start, end)
} else {
(end, start)
};
let composing = element.composing.as_deref().and_then(|composing| {
let length = composing.chars().count();
(length > 0 && length <= end).then(|| (end - length, end))
});
let edit = Edit {
cursor,
anchor: (anchor != cursor).then_some(anchor),
composing,
};
(text.clone(), edit)
}
pub(crate) fn selection(text: &str, edit: &Edit) -> (u32, u32, bool) {
let at = |index: usize| text.chars().take(index).map(char::len_utf16).sum::<usize>() as u32;
let cursor = edit.cursor;
match edit.anchor {
Some(anchor) if at(anchor) != at(cursor) => (
at(cursor.min(anchor)),
at(cursor.max(anchor)),
cursor < anchor,
),
_ => (at(cursor), at(cursor), false),
}
}
pub(crate) fn browser_edits(
key: &str,
ctrl: bool,
alt: bool,
meta: bool,
shift: bool,
composing: bool,
mac: bool,
) -> bool {
if composing || matches!(key, "Process" | "Dead" | "Unidentified" | "Compose") {
return true;
}
let printable = key.chars().count() == 1;
let alt_gr = ctrl && alt && !meta;
if (ctrl || meta) && !alt_gr {
return matches!(
key.to_ascii_lowercase().as_str(),
"c" | "x" | "v" | "insert" | "backspace" | "delete"
);
}
if alt && !alt_gr && !mac {
return false;
}
printable || matches!(key, "Backspace" | "Delete") || (shift && key == "Insert")
}
#[cfg(web)]
pub(crate) use web::{Editor, Field};
#[cfg(web)]
mod web {
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use frus_widgets::{Edit, Rect, WebInput, WidgetId};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{
CompositionEvent, Document, Event, HtmlCanvasElement, HtmlElement, HtmlInputElement,
HtmlTextAreaElement, KeyboardEvent, KeyboardEventInit,
};
use winit::platform::web::WindowExtWebSys;
use winit::window::Window;
use super::{browser_edits, selection, Element};
pub(crate) struct Field<'a> {
pub id: WidgetId,
pub rect: Rect,
pub web: WebInput,
pub label: Option<&'a str>,
pub text: &'a str,
pub edit: Edit,
}
type Changes = Rc<RefCell<Vec<(WidgetId, Element)>>>;
type Listener = Closure<dyn FnMut(Event)>;
struct Live {
id: WidgetId,
web: WebInput,
el: HtmlElement,
composing: Rc<RefCell<Option<String>>>,
_listeners: Vec<Listener>,
}
pub(crate) struct Editor {
document: Document,
canvas: HtmlCanvasElement,
window: Arc<Window>,
live: Option<Live>,
changes: Changes,
mac: bool,
_selection: Listener,
}
impl Editor {
pub(crate) fn new(window: &Arc<Window>) -> Option<Self> {
let canvas = window.canvas()?;
let document = canvas.owner_document()?;
let changes: Changes = Rc::default();
let mac = web_sys::window()
.and_then(|w| w.navigator().platform().ok())
.is_some_and(|platform| platform.starts_with("Mac"));
let selection = {
let changes = changes.clone();
let document = document.clone();
let window = window.clone();
Closure::<dyn FnMut(Event)>::new(move |_: Event| {
let Some(active) = document.active_element() else {
return;
};
let Some(id) = active
.get_attribute("data-frus-field")
.and_then(|id| id.parse::<u64>().ok())
else {
return;
};
let Ok(el) = active.dyn_into::<HtmlElement>() else {
return;
};
changes
.borrow_mut()
.push((WidgetId::from_u64(id), state(&el, None)));
window.request_redraw();
})
};
document
.add_event_listener_with_callback(
"selectionchange",
selection.as_ref().unchecked_ref(),
)
.ok()?;
Some(Self {
document,
canvas,
window: window.clone(),
live: None,
changes,
mac,
_selection: selection,
})
}
pub(crate) fn editing(&self) -> Option<WidgetId> {
self.live.as_ref().map(|live| live.id)
}
pub(crate) fn take_changes(&self) -> Vec<(WidgetId, Element)> {
std::mem::take(&mut self.changes.borrow_mut())
}
pub(crate) fn sync(&mut self, field: Option<Field>, css: f32) {
let keep = matches!(
(&self.live, &field),
(Some(live), Some(field)) if live.id == field.id && live.web == field.web
);
if !keep {
if let Some(live) = self.live.take() {
self.remove(live, field.is_none());
}
}
let Some(field) = field else {
return;
};
let fresh = self.live.is_none();
if fresh {
self.live = self.spawn(&field);
}
let Some(live) = self.live.as_ref() else {
return;
};
let bounds = self.canvas.get_bounding_client_rect();
let style = format!(
"{STYLE}left:{}px;top:{}px;width:{}px;height:{}px;",
bounds.left() + f64::from(field.rect.x * css),
bounds.top() + f64::from(field.rect.y * css),
(field.rect.width * css).max(1.0),
(field.rect.height * css).max(1.0),
);
if live.el.get_attribute("style").as_deref() != Some(style.as_str()) {
live.el.set_attribute("style", &style).ok();
}
match field.label {
Some(label) => live.el.set_attribute("aria-label", label).ok(),
None => live.el.remove_attribute("aria-label").ok(),
};
if live.composing.borrow().is_none() {
if value(&live.el) != field.text {
set_value(&live.el, field.text);
}
let (start, end, backward) = selection(field.text, &field.edit);
let now = state(&live.el, None);
if (now.start, now.end, now.backward && now.start != now.end)
!= (start, end, backward)
{
set_selection(&live.el, start, end, backward);
}
}
let active = self.document.active_element();
if fresh
|| active
.as_ref()
.is_none_or(|active| self.adrift(active, &live.el))
{
let _ = live.el.focus();
}
}
fn adrift(&self, active: &web_sys::Element, el: &HtmlElement) -> bool {
if active == AsRef::<web_sys::Element>::as_ref(el) {
return false;
}
let canvas: &web_sys::Element = self.canvas.as_ref();
let body = self.document.body();
active == canvas
|| body.is_some_and(|body| active == AsRef::<web_sys::Element>::as_ref(&body))
|| (self.canvas.contains(Some(active))
&& active.get_attribute("role").as_deref() == Some("textbox"))
}
fn spawn(&self, field: &Field) -> Option<Live> {
let web = field.web;
let el: HtmlElement = if web.multiline {
self.document
.create_element("textarea")
.ok()?
.dyn_into()
.ok()?
} else {
let input: HtmlInputElement = self
.document
.create_element("input")
.ok()?
.dyn_into()
.ok()?;
input.set_type(web.input_type);
input.into()
};
el.set_attribute("data-frus-field", &field.id.as_u64().to_string())
.ok();
el.set_attribute("inputmode", web.inputmode).ok();
if let Some(hint) = web.enterkeyhint {
el.set_attribute("enterkeyhint", hint).ok();
}
el.set_attribute("autocapitalize", web.autocapitalize).ok();
let corrects = if web.corrects { "on" } else { "off" };
el.set_attribute("autocorrect", corrects).ok();
el.set_attribute("spellcheck", if web.corrects { "true" } else { "false" })
.ok();
el.set_attribute("autocomplete", "off").ok();
let id = field.id;
let composing: Rc<RefCell<Option<String>>> = Rc::default();
let mut listeners = Vec::new();
let mut listen = |kind: &str, listener: Listener| {
let _ =
el.add_event_listener_with_callback(kind, listener.as_ref().unchecked_ref());
listeners.push(listener);
};
let changed = {
let changes = self.changes.clone();
let composing = composing.clone();
let window = self.window.clone();
move |el: &HtmlElement| {
let open = composing.borrow().clone();
changes.borrow_mut().push((id, state(el, open)));
window.request_redraw();
}
};
{
let changed = changed.clone();
listen(
"input",
Closure::new(move |event: Event| {
if let Some(el) = target(&event) {
changed(&el);
}
}),
);
}
{
let composing = composing.clone();
listen(
"compositionstart",
Closure::new(move |_: Event| {
*composing.borrow_mut() = Some(String::new());
}),
);
}
{
let composing = composing.clone();
listen(
"compositionupdate",
Closure::new(move |event: Event| {
let data = event
.dyn_ref::<CompositionEvent>()
.and_then(CompositionEvent::data)
.unwrap_or_default();
*composing.borrow_mut() = Some(data);
}),
);
}
{
let composing = composing.clone();
let changed = changed.clone();
listen(
"compositionend",
Closure::new(move |event: Event| {
*composing.borrow_mut() = None;
if let Some(el) = target(&event) {
changed(&el);
}
}),
);
}
for kind in ["keydown", "keyup"] {
let canvas = self.canvas.clone();
let mac = self.mac;
listen(
kind,
Closure::new(move |event: Event| {
let Some(key) = event.dyn_ref::<KeyboardEvent>() else {
return;
};
let browsers = browser_edits(
&key.key(),
key.ctrl_key(),
key.alt_key(),
key.meta_key(),
key.shift_key(),
key.is_composing(),
mac,
);
if browsers {
return;
}
key.prevent_default();
forward(&canvas, kind, key);
}),
);
}
if let Some(parent) = self.canvas.parent_node() {
parent.append_child(&el).ok()?;
} else {
self.document.body()?.append_child(&el).ok()?;
}
Some(Live {
id,
web,
el,
composing,
_listeners: listeners,
})
}
fn remove(&self, live: Live, last: bool) {
let had_focus = self
.document
.active_element()
.is_some_and(|active| active == *AsRef::<web_sys::Element>::as_ref(&live.el));
live.el.remove();
if last && had_focus {
let _ = self.canvas.focus();
}
}
}
const STYLE: &str = "position:fixed;margin:0;padding:0;border:0;outline:none;\
background:transparent;color:transparent;caret-color:transparent;\
text-shadow:none;resize:none;overflow:hidden;pointer-events:none;\
opacity:0;z-index:1;";
fn target(event: &Event) -> Option<HtmlElement> {
event.target()?.dyn_into::<HtmlElement>().ok()
}
fn value(el: &HtmlElement) -> String {
if let Some(input) = el.dyn_ref::<HtmlInputElement>() {
input.value()
} else if let Some(area) = el.dyn_ref::<HtmlTextAreaElement>() {
area.value()
} else {
String::new()
}
}
fn set_value(el: &HtmlElement, text: &str) {
if let Some(input) = el.dyn_ref::<HtmlInputElement>() {
input.set_value(text);
} else if let Some(area) = el.dyn_ref::<HtmlTextAreaElement>() {
area.set_value(text);
}
}
fn set_selection(el: &HtmlElement, start: u32, end: u32, backward: bool) {
let direction = if backward { "backward" } else { "forward" };
if let Some(input) = el.dyn_ref::<HtmlInputElement>() {
let _ = input.set_selection_range_with_direction(start, end, direction);
} else if let Some(area) = el.dyn_ref::<HtmlTextAreaElement>() {
let _ = area.set_selection_range_with_direction(start, end, direction);
}
}
fn state(el: &HtmlElement, composing: Option<String>) -> Element {
let (start, end, direction) = if let Some(input) = el.dyn_ref::<HtmlInputElement>() {
(
input.selection_start().ok().flatten(),
input.selection_end().ok().flatten(),
input.selection_direction().ok().flatten(),
)
} else if let Some(area) = el.dyn_ref::<HtmlTextAreaElement>() {
(
area.selection_start().ok().flatten(),
area.selection_end().ok().flatten(),
area.selection_direction().ok().flatten(),
)
} else {
(None, None, None)
};
let value = value(el);
let length = value.encode_utf16().count() as u32;
let end = end.unwrap_or(length);
Element {
value,
start: start.unwrap_or(end),
end,
backward: direction.as_deref() == Some("backward"),
composing,
}
}
fn forward(canvas: &HtmlCanvasElement, kind: &str, key: &KeyboardEvent) {
let init = KeyboardEventInit::new();
init.set_key(&key.key());
init.set_code(&key.code());
init.set_location(key.location());
init.set_repeat(key.repeat());
init.set_ctrl_key(key.ctrl_key());
init.set_shift_key(key.shift_key());
init.set_alt_key(key.alt_key());
init.set_meta_key(key.meta_key());
init.set_bubbles(true);
init.set_cancelable(true);
if let Ok(event) = KeyboardEvent::new_with_keyboard_event_init_dict(kind, &init) {
let _ = canvas.dispatch_event(&event);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn element(value: &str, start: u32, end: u32) -> Element {
Element {
value: value.to_owned(),
start,
end,
..Element::default()
}
}
#[test]
fn utf16_offsets_become_characters() {
let text = "a😀b";
assert_eq!(char_index(text, 0), 0);
assert_eq!(char_index(text, 1), 1);
assert_eq!(char_index(text, 2), 1, "inside the pair");
assert_eq!(char_index(text, 3), 2);
assert_eq!(char_index(text, 4), 3);
assert_eq!(char_index(text, 99), 3, "past the end is the end");
}
#[test]
fn the_selection_comes_back_with_its_direction() {
let (text, edit) = read(&element("hello", 2, 2));
assert_eq!(text, "hello");
assert_eq!((edit.cursor, edit.anchor, edit.composing), (2, None, None));
let (_, forwards) = read(&element("hello", 1, 4));
assert_eq!((forwards.cursor, forwards.anchor), (4, Some(1)));
let backwards = Element {
backward: true,
..element("hello", 1, 4)
};
let (_, backwards) = read(&backwards);
assert_eq!((backwards.cursor, backwards.anchor), (1, Some(4)));
}
#[test]
fn a_composition_ends_at_the_caret() {
let composing = Element {
composing: Some("ni".into()),
..element("anib", 3, 3)
};
let (text, edit) = read(&composing);
assert_eq!(text, "anib");
assert_eq!(edit.composing, Some((1, 3)));
let (_, committed) = read(&element("a你b", 2, 2));
assert_eq!(committed.composing, None);
let started = Element {
composing: Some(String::new()),
..element("ab", 1, 1)
};
assert_eq!(read(&started).1.composing, None);
}
#[test]
fn the_field_selection_goes_to_the_element() {
let caret = Edit {
cursor: 2,
..Edit::default()
};
assert_eq!(selection("a😀b", &caret), (3, 3, false));
let backwards = Edit {
cursor: 0,
anchor: Some(2),
composing: None,
};
assert_eq!(selection("a😀b", &backwards), (0, 3, true));
let forwards = Edit {
cursor: 3,
anchor: Some(1),
composing: None,
};
assert_eq!(selection("a😀b", &forwards), (1, 4, false));
let past = Edit {
cursor: 9,
anchor: Some(7),
composing: None,
};
assert_eq!(
selection("ab", &past),
(2, 2, false),
"both ends at the end"
);
}
fn plain(key: &str) -> bool {
browser_edits(key, false, false, false, false, false, false)
}
fn ctrl(key: &str) -> bool {
browser_edits(key, true, false, false, false, false, false)
}
#[test]
fn typing_deleting_and_the_clipboard_are_the_browsers() {
for key in [
"a",
"Z",
"é",
" ",
"7",
"Backspace",
"Delete",
"Dead",
"Process",
] {
assert!(plain(key), "{key}");
}
for key in ["c", "x", "v", "V", "Backspace", "Delete", "Insert"] {
assert!(ctrl(key), "Ctrl+{key}");
}
assert!(browser_edits("v", false, false, true, false, false, true));
assert!(browser_edits(
"Insert", false, false, false, true, false, false
));
assert!(browser_edits(
"Enter", false, false, false, false, true, false
));
assert!(browser_edits(
"ArrowLeft",
false,
false,
false,
false,
true,
false
));
}
#[test]
fn moving_submitting_and_shortcuts_are_the_shells() {
for key in [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
"Enter",
"Tab",
"Escape",
"F2",
"ContextMenu",
"Shift",
"Control",
] {
assert!(!plain(key), "{key}");
}
for key in ["a", "z", "y", "s", "k", "ArrowLeft"] {
assert!(!ctrl(key), "Ctrl+{key}");
}
}
#[test]
fn alt_gr_types_and_alt_is_a_shortcut_but_on_a_mac() {
assert!(browser_edits("€", true, true, false, false, false, false));
assert!(!browser_edits("f", false, true, false, false, false, false));
assert!(browser_edits("ƒ", false, true, false, false, false, true));
}
}