Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions packages/blitz-dom/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ pub struct BaseDocument {
pub(crate) last_client_pointer_position: Option<taffy::Point<f32>>,
/// The node which is currently focussed (if any)
pub(crate) focus_node_id: Option<NodeId>,
/// The IME cursor area most recently reported to the shell for the focussed text input
pub(crate) last_ime_cursor_area: Option<(f32, f32, f32, f32)>,
/// The node which is currently active (if any)
pub(crate) active_node_id: Option<NodeId>,
/// The node which recieved a mousedown event (if any)
Expand Down Expand Up @@ -458,6 +460,7 @@ impl BaseDocument {
hover_node_is_text: false,
last_client_pointer_position: None,
focus_node_id: None,
last_ime_cursor_area: None,
active_node_id: None,
mousedown_node_id: None,
has_active_animations: false,
Expand Down Expand Up @@ -1672,10 +1675,25 @@ impl BaseDocument {
);

self.focus_node_id = Some(focus_node_id);
self.last_ime_cursor_area = self.nodes[focus_node_id].ime_cursor_area();

true
}

/// Report the focussed text input's content box to the shell as the IME cursor area,
/// if it has changed since it was last reported. Must be called after layout.
pub(crate) fn sync_ime_cursor_area(&mut self) {
let Some(node) = self.focus_node_id.and_then(|id| self.get_node(id)) else {
return;
};
let area = node.ime_cursor_area();
if let Some((x, y, width, height)) = area.filter(|a| Some(*a) != self.last_ime_cursor_area)
{
self.shell_provider.set_ime_cursor_area(x, y, width, height);
self.last_ime_cursor_area = area;
}
}

pub fn active_node(&mut self) -> bool {
let Some(hover_node_id) = self.get_hover_node_id() else {
return false;
Expand Down Expand Up @@ -3296,3 +3314,61 @@ mod font_face_override_tests {
);
}
}

#[cfg(test)]
mod ime_focus_tests {
use super::*;
use crate::qual_name;
use blitz_traits::shell::{ColorScheme, ShellProvider};
use std::sync::Mutex;

#[derive(Default)]
struct RecordingShell {
enabled: Mutex<Vec<bool>>,
areas: Mutex<Vec<(f32, f32, f32, f32)>>,
}
impl ShellProvider for RecordingShell {
fn set_ime_enabled(&self, is_enabled: bool) {
self.enabled.lock().unwrap().push(is_enabled);
}
fn set_ime_cursor_area(&self, x: f32, y: f32, width: f32, height: f32) {
self.areas.lock().unwrap().push((x, y, width, height));
}
}

/// An input focussed before the first layout (e.g. via `autofocus`) must still enable
/// the IME, and its cursor area must be reported once layout has run.
#[test]
fn focus_before_layout_enables_ime() {
let shell = Arc::new(RecordingShell::default());
let mut doc = BaseDocument::new(DocumentConfig {
viewport: Some(Viewport::new(400, 300, 1.0, ColorScheme::Light)),
shell_provider: Some(shell.clone()),
..Default::default()
});
doc.add_user_agent_stylesheet("html, body { display: block; margin: 0 } input { display: block; width: 100px; height: 20px; padding: 0; border: 0 }");
let root_id = doc.root_node().id;
let mut mutator = doc.mutate();
let html = mutator.create_element(qual_name!("html"), vec![]);
let body = mutator.create_element(qual_name!("body"), vec![]);
let input = mutator.create_element(qual_name!("input"), vec![]);
mutator.append_children(body, &[input]);
mutator.append_children(html, &[body]);
mutator.append_children(root_id, &[html]);
drop(mutator);

doc.set_focus_to(input);
assert_eq!(*shell.enabled.lock().unwrap(), vec![true]);
assert!(shell.areas.lock().unwrap().is_empty());

doc.resolve(0.0);
assert_eq!(*shell.areas.lock().unwrap(), vec![(0.0, 0.0, 100.0, 20.0)]);

// Unchanged layout does not re-report the area
doc.resolve(0.0);
assert_eq!(shell.areas.lock().unwrap().len(), 1);

doc.clear_focus();
assert_eq!(*shell.enabled.lock().unwrap(), vec![true, false]);
}
}
15 changes: 15 additions & 0 deletions packages/blitz-dom/src/node/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,21 @@ impl ElementData {
local_names!("button", "input", "select", "textarea").contains(&self.name.local)
}

/// Whether this element is a `<textarea>` or an `<input>` of a type that takes text
/// input (and so gets a text editor and receives IME events). Unlike
/// [`text_input_data`](Self::text_input_data), this does not depend on layout
/// having run yet.
pub fn is_text_input(&self) -> bool {
if self.name.local == local_name!("textarea") {
return true;
}
self.name.local == local_name!("input")
&& matches!(
self.attr(local_name!("type")),
None | Some("text" | "password" | "email" | "number" | "search" | "tel" | "url")
)
}

/// Whether this element is a link (an `<a>` or `<area>` element with an `href` attribute)
pub fn is_link(&self) -> bool {
(self.name.local == local_name!("a") || self.name.local == local_name!("area"))
Expand Down
41 changes: 25 additions & 16 deletions packages/blitz-dom/src/node/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -667,21 +667,34 @@ impl Node {
self.mark_ancestors_dirty();

// If focussing a text input, enable IME and set IME area
if self
.element_data()
.and_then(|elem| elem.text_input_data())
.is_some()
{
if self.is_text_input() {
shell_provider.set_ime_enabled(true);
let mut pos = self.absolute_position(0.0, 0.0);
pos.x += self.final_layout().content_box_x();
pos.y += self.final_layout().content_box_y();
let width = self.final_layout().content_box_width();
let height = self.final_layout().content_box_height();
shell_provider.set_ime_cursor_area(pos.x, pos.y, width, height);
if let Some((x, y, width, height)) = self.ime_cursor_area() {
shell_provider.set_ime_cursor_area(x, y, width, height);
}
}
}

/// Whether this node is a text input element (see [`ElementData::is_text_input`])
pub fn is_text_input(&self) -> bool {
self.element_data().is_some_and(|el| el.is_text_input())
}

/// The node's content box as `(x, y, width, height)` in CSS pixels, for use as the IME
/// cursor area. Returns `None` if the node is not a text input or has not been laid out
/// yet (e.g. it was focussed before the first layout).
pub fn ime_cursor_area(&self) -> Option<(f32, f32, f32, f32)> {
self.element_data()?.text_input_data()?;
let layout = self.final_layout();
let pos = self.absolute_position(0.0, 0.0);
Some((
pos.x + layout.content_box_x(),
pos.y + layout.content_box_y(),
layout.content_box_width(),
layout.content_box_height(),
))
}

pub fn blur(&mut self, shell_provider: Arc<dyn ShellProvider>) {
if let Some(data) = self.element_data_mut() {
data.element_state
Expand All @@ -690,11 +703,7 @@ impl Node {
self.mark_ancestors_dirty();

// If blurring a text input, disable IME
if self
.element_data()
.and_then(|elem| elem.text_input_data())
.is_some()
{
if self.is_text_input() {
shell_provider.set_ime_enabled(false);
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/blitz-dom/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ impl BaseDocument {
// if the hovered node actually changes.
self.refresh_hover();

// Keep the IME cursor area in sync with the (possibly moved) focussed text input.
// This also covers inputs focussed before their first layout (e.g. autofocus),
// for which `Node::focus` could not report an area.
self.sync_ime_cursor_area();

let mut subdoc_is_animating = false;
for &node_id in &self.sub_document_nodes {
let node = &mut self.nodes[node_id];
Expand Down
Loading