gpui_web: Tighten up web keyboard feel (#63533)

Fixes a few edge cases where we weren't correctly tracking whether to
hide the keyboard when touch events happen

Also forces a synchronous render on web after the viewport resizes
(which happens when the keyboard appears/disappears). This prevents a 1
frame delay where the browser would use the old bitmap, stretched to the
new size

---

Release Notes:

- N/A or Added/Fixed/Improved ...
This commit is contained in:
Cameron Mcloughlin authored and GitHub committed 2026-09-01 12:43:44 +00:00
1 parent ce48461eaa
commit a66fb6aebd
4 files changed
+88 -14

No files matched your search

+14 -12
View File
@@ -3309,13 +3309,14 @@ impl Interactivity {
if let Some(group_hover) = self.group_hover_style.as_ref() {
let is_group_hovered =
if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) {
group_hitbox_id.is_hovered(window)
!window.last_input_was_touch() && group_hitbox_id.is_hovered(window)
} else if let Some(element_state) = element_state.as_ref() {
element_state
.hover_state
.as_ref()
.map(|state| state.borrow().group)
.unwrap_or(false)
!window.last_input_was_touch()
&& element_state
.hover_state
.as_ref()
.map(|state| state.borrow().group)
.unwrap_or(false)
} else {
false
};
@@ -3327,13 +3328,14 @@ impl Interactivity {
if let Some(hover_style) = self.hover_style.as_ref() {
let is_hovered = if let Some(hitbox) = hitbox {
hitbox.is_hovered(window)
!window.last_input_was_touch() && hitbox.is_hovered(window)
} else if let Some(element_state) = element_state.as_ref() {
element_state
.hover_state
.as_ref()
.map(|state| state.borrow().element)
.unwrap_or(false)
!window.last_input_was_touch()
&& element_state
.hover_state
.as_ref()
.map(|state| state.borrow().element)
.unwrap_or(false)
} else {
false
};
+4
View File
@@ -2849,6 +2849,10 @@ impl Window {
self.last_input_modality == InputModality::Keyboard
}
pub(crate) fn last_input_was_touch(&self) -> bool {
self.last_input_modality == InputModality::Touch
}
/// The current state of the keyboard's capslock
pub fn capslock(&self) -> Capslock {
self.capslock
+56 -2
View File
@@ -264,6 +264,11 @@ impl WebWindowInner {
.unwrap_or(false)
}
fn focused_input_accepts_text(&self) -> bool {
self.with_input_handler(|handler| handler.query_accepts_text_input())
.unwrap_or(false)
}
fn register_pointer_up(self: &Rc<Self>) -> EventListenerHandle {
let this = Rc::clone(self);
self.listen("pointerup", move |event: JsValue| {
@@ -281,10 +286,11 @@ impl WebWindowInner {
}
_ => false,
};
let focused_input_accepted_text_before_tap = this.focused_input_accepts_text();
// A recognized tap is dispatched synchronously inside this
// call, so the text-input check below sees the state the tap
// produced.
this.dispatch_input(PlatformInput::Touch(TouchEvent {
let dispatch_result = this.dispatch_input(PlatformInput::Touch(TouchEvent {
id: TouchId(event.pointer_id() as u64),
phase: TouchPhase::Ended,
position,
@@ -302,7 +308,14 @@ impl WebWindowInner {
let viewport_stable = this.gesture_start_visual_viewport_height.get()
== this.visual_viewport_height();
if completes_tap && viewport_stable {
this.sync_virtual_keyboard(this.pointer_targets_text_input(position));
let preserve_focused_input = should_preserve_focused_input(
focused_input_accepted_text_before_tap,
this.focused_input_accepts_text(),
dispatch_result,
);
if !preserve_focused_input {
this.sync_virtual_keyboard(this.pointer_targets_text_input(position));
}
}
this.schedule_ime_mirror_sync();
return;
@@ -1231,6 +1244,16 @@ fn capslock_from_keyboard_event(event: &web_sys::KeyboardEvent) -> Capslock {
}
}
fn should_preserve_focused_input(
accepted_text_before_tap: bool,
accepts_text_after_tap: bool,
dispatch_result: Option<DispatchEventResult>,
) -> bool {
accepted_text_before_tap
&& accepts_text_after_tap
&& dispatch_result.is_some_and(|result| result.default_prevented)
}
pub(crate) fn is_mac_platform(browser_window: &web_sys::Window) -> bool {
let navigator = browser_window.navigator();
@@ -1364,3 +1387,34 @@ fn mouse_position_in_element(event: &web_sys::MouseEvent) -> Point<Pixels> {
// offset_x/offset_y give position relative to the target element's padding edge
point(px(event.offset_x() as f32), px(event.offset_y() as f32))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handled_tap_preserves_unchanged_text_input() {
assert!(should_preserve_focused_input(
true,
true,
Some(DispatchEventResult {
propagate: false,
default_prevented: true,
}),
));
}
#[test]
fn tap_does_not_preserve_unhandled_or_unfocused_input() {
let result = |default_prevented| {
Some(DispatchEventResult {
propagate: false,
default_prevented,
})
};
assert!(!should_preserve_focused_input(true, true, result(false),));
assert!(!should_preserve_focused_input(false, true, result(true),));
assert!(!should_preserve_focused_input(true, false, result(true),));
}
}
+14
View File
@@ -335,6 +335,20 @@ impl WebWindow {
|callbacks| &mut callbacks.resize,
|callback| callback(new_size, dpr_f32),
);
// ResizeObserver runs after layout but before the browser paints.
// Render synchronously here so the newly resized CSS canvas is
// never presented with its previous backing image stretched into
// the new viewport dimensions.
inner.with_callback(
|callbacks| &mut callbacks.request_frame,
|callback| {
callback(RequestFrameOptions {
require_presentation: true,
force_render: true,
})
},
);
})
}
}