From 9decdcc1f22d324b5d5b186a3da1418ec0ea4c37 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 2 Sep 2026 18:23:07 +0000 Subject: [PATCH] gpui_web: Drive IME mirror focus state from GPUI (#63629) Previously, we manually synchronized the keyboard state in response to tap events, but this meant that several common cases were unreliable. Now, we use GPUI's focused element as the source of truth, and use `sync_virtual_keyboard` in cases where they have gotten out of sync (i.e. a user manually dismisses a keyboard while the input is still focused). --- Release Notes: - N/A or Added/Fixed/Improved ... --- crates/gpui/src/input.rs | 19 +++++++++++++++-- crates/gpui/src/platform/test/window.rs | 15 ++++++++++++-- crates/gpui/src/window.rs | 27 +++++++++++++++++++------ crates/gpui_web/src/events.rs | 2 +- crates/gpui_web/src/window.rs | 13 ++++++++++-- 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index cbf6e405cf..4167aa0990 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -288,11 +288,12 @@ mod tests { use super::*; use crate::{ AnyWindowHandle, AppContext as _, FocusHandle, InteractiveElement as _, IntoElement, - ParentElement as _, Render, Styled as _, TestAppContext, TextInputAction, canvas, div, + ParentElement as _, Render, Styled as _, TestAppContext, TextInputAction, + TextInputStateChange, canvas, div, }; #[gpui::test] - fn text_input_configuration_forwarded_only_on_change(cx: &mut TestAppContext) { + fn text_input_configuration_and_focus_state_are_forwarded_on_change(cx: &mut TestAppContext) { let custom = TextInputConfiguration { autocorrect: true, input_action: TextInputAction::Send, @@ -319,6 +320,7 @@ mod tests { test_window.text_input_configurations(), vec![TextInputConfiguration::default()] ); + assert!(test_window.text_input_state_changes().is_empty()); // Focusing the view routes its configuration to the platform. cx.update_window(window, |_, window, cx| { @@ -331,10 +333,15 @@ mod tests { test_window.text_input_configurations(), vec![TextInputConfiguration::default(), custom.clone()] ); + assert_eq!( + test_window.text_input_state_changes(), + vec![TextInputStateChange::FocusGained] + ); // Redrawing without a change forwards nothing. draw(cx); assert_eq!(test_window.text_input_configurations().len(), 2); + assert_eq!(test_window.text_input_state_changes().len(), 1); // Changing the configuration forwards the new value. let updated = TextInputConfiguration { @@ -354,6 +361,7 @@ mod tests { Some(&updated) ); assert_eq!(test_window.text_input_configurations().len(), 3); + assert_eq!(test_window.text_input_state_changes().len(), 1); // Losing focus reverts the platform to the default configuration. cx.update_window(window, |_, window, cx| window.blur(cx)) @@ -364,6 +372,13 @@ mod tests { Some(&TextInputConfiguration::default()) ); assert_eq!(test_window.text_input_configurations().len(), 4); + assert_eq!( + test_window.text_input_state_changes(), + vec![ + TextInputStateChange::FocusGained, + TextInputStateChange::FocusLost + ] + ); } struct ConfigurationTestView { diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 223a2370e4..38926b649c 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -2,8 +2,9 @@ use crate::{ AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, - PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration, TileId, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, + PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration, + TextInputStateChange, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, + WindowControlArea, WindowParams, }; use collections::HashMap; use gpui_util::ResultExt as _; @@ -43,6 +44,7 @@ pub(crate) struct TestWindowState { frame_callback_pending: bool, input_handler: Option, text_input_configurations: Vec, + text_input_state_changes: Vec, is_fullscreen: bool, appearance: WindowAppearance, external_drag_files: Vec<(PathBuf, bool)>, @@ -106,6 +108,7 @@ impl TestWindow { frame_callback_pending: false, input_handler: None, text_input_configurations: Vec::new(), + text_input_state_changes: Vec::new(), is_fullscreen: false, appearance: WindowAppearance::Light, external_drag_files: Vec::new(), @@ -140,6 +143,10 @@ impl TestWindow { self.0.lock().text_input_configurations.clone() } + pub fn text_input_state_changes(&self) -> Vec { + self.0.lock().text_input_state_changes.clone() + } + pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); @@ -269,6 +276,10 @@ impl PlatformWindow for TestWindow { self.0.lock().text_input_configurations.push(configuration); } + fn text_input_state_changed(&self, change: TextInputStateChange) { + self.0.lock().text_input_state_changes.push(change); + } + fn prompt( &self, _level: crate::PromptLevel, diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 63b11d95b5..0950071c3f 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -19,10 +19,10 @@ use crate::{ SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab, SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextInputConfiguration, - TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix, - Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, point, - prelude::*, px, rems, size, transparent_black, + TextInputStateChange, TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, + TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, + WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, + point, prelude::*, px, rems, size, transparent_black, }; use crate::gestures::{GestureTuning, RecognizedTouchGesture, TouchGestureRecognizer}; @@ -1162,6 +1162,7 @@ pub struct Window { /// window, so that only actual changes are forwarded (reconfiguring a live /// input session can restart the IME connection). last_text_input_configuration: Option, + focused_text_input_active: bool, pub(crate) image_cache_stack: Vec, pub(crate) rendered_frame: Frame, pub(crate) next_frame: Frame, @@ -1859,6 +1860,7 @@ impl Window { element_opacity: 1.0, requested_autoscroll: None, last_text_input_configuration: None, + focused_text_input_active: false, rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame_callbacks, @@ -2935,16 +2937,29 @@ impl Window { // paint_range indices remain valid for reuse_paint on the next frame. // Search backwards to find the last Some entry, since reuse_paint may // have copied None slots from the previous frame. (Fixes #50456) - if let Some(input_handler) = self + let focused_text_input_active = if let Some(mut input_handler) = self .next_frame .input_handlers .iter_mut() .rev() .find_map(|h| h.take()) { + let accepts_text_input = input_handler.accepts_text_input(self, cx); self.platform_window.set_input_handler(input_handler); - } + accepts_text_input + } else { + false + }; self.apply_text_input_configuration(cx); + if focused_text_input_active != self.focused_text_input_active { + self.focused_text_input_active = focused_text_input_active; + self.platform_window + .text_input_state_changed(if focused_text_input_active { + TextInputStateChange::FocusGained + } else { + TextInputStateChange::FocusLost + }); + } self.layout_engine.as_mut().unwrap().clear(); self.text_system().finish_frame(); diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index 7afedeb4e8..a6a1b2f4be 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -480,7 +480,7 @@ impl WebWindowInner { /// /// We don't use `navigator.virtualKeyboard` here because it's /// Chromium-only. - fn sync_virtual_keyboard(self: &Rc, editable: bool) { + pub(crate) fn sync_virtual_keyboard(self: &Rc, editable: bool) { let was_editable = !self.ime_mirror.read_only(); self.ime_mirror.set_read_only(!editable); // Trigger a focus event only when the keyboard actually needs diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index 799c3fadcf..8d6b0bd688 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -11,8 +11,9 @@ use gpui::{ AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, DispatchEventResult, GpuSpecs, Modifiers, MouseButton, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, - ResizeEdge, Scene, Size, TextInputConfiguration, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, px, + ResizeEdge, Scene, Size, TextInputConfiguration, TextInputStateChange, WindowAppearance, + WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations, + WindowParams, px, }; use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig, wgpu}; use wasm_bindgen::prelude::*; @@ -691,6 +692,14 @@ impl PlatformWindow for WebWindow { self.inner.ime_mirror.apply_configuration(&configuration); } + fn text_input_state_changed(&self, change: TextInputStateChange) { + match change { + TextInputStateChange::FocusGained => self.inner.sync_virtual_keyboard(true), + TextInputStateChange::FocusLost => self.inner.sync_virtual_keyboard(false), + TextInputStateChange::SelectionChanged | TextInputStateChange::ContentChanged => {} + } + } + fn prompt( &self, _level: PromptLevel,