From c940c0c669a6f2851173b09e01b6de9bcd86f380 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Tue, 1 Sep 2026 12:43:44 +0000 Subject: [PATCH] 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 ... --- crates/gpui/src/elements/div.rs | 26 ++++++++------- crates/gpui/src/window.rs | 4 +++ crates/gpui_web/src/events.rs | 58 +++++++++++++++++++++++++++++++-- crates/gpui_web/src/window.rs | 14 ++++++++ 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 70e63d7321..98275cd189 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -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 }; diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index c2fa5ebedd..97cd9f99f5 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -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 diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index 97b7ad24ed..886803cc8d 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -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) -> 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, +) -> 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 { // 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),)); + } +} diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index a1690ac8de..08c43bae9c 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -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, + }) + }, + ); }) } }