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 ...
This commit is contained in:
Cameron Mcloughlin authored and GitHub committed 2026-09-02 18:23:07 +00:00
1 parent 6b5e15ed46
commit 9decdcc1f2
5 files changed
+63 -13

No files matched your search

+17 -2
View File
@@ -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 {
+13 -2
View File
@@ -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<PlatformInputHandler>,
text_input_configurations: Vec<TextInputConfiguration>,
text_input_state_changes: Vec<TextInputStateChange>,
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<TextInputStateChange> {
self.0.lock().text_input_state_changes.clone()
}
pub fn simulate_resize(&mut self, size: Size<Pixels>) {
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,
+21 -6
View File
@@ -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<TextInputConfiguration>,
focused_text_input_active: bool,
pub(crate) image_cache_stack: Vec<AnyImageCache>,
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();
+1 -1
View File
@@ -480,7 +480,7 @@ impl WebWindowInner {
///
/// We don't use `navigator.virtualKeyboard` here because it's
/// Chromium-only.
fn sync_virtual_keyboard(self: &Rc<Self>, editable: bool) {
pub(crate) fn sync_virtual_keyboard(self: &Rc<Self>, 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
+11 -2
View File
@@ -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,