gpui_web: Simplify keyboard focus model (#63989)

Simplifies the mobile web virtual keyboard implementation in the
following ways:
- assumes that GPUI web apps will set
`interactive-widget=resizes-visual`. This means that GPUI's window will
not change viewport size when the keyboard appears
- Exposes `Window::fully_visible_bounds`, which returns a rectangle
which is fully visible (pixels outside these bounds *may* be visible but
may not be)
- These two changes together mean that keyboard visibility is more
reliably detectable (though not fully), and also makes it easier to have
a stable scroll position when the keyboard is animating in/out
- tweaks the focus model so that:
- transitions into/out of the state "GPUI focus is in a text input, and
that text input is in an editable range" will change keyboard visibility
- manually-dismissed keyboards are preserved by not synchronizing unless
a tap event (or similar, or a programmatic request) on that input
happens
- there is a new `Window::{request,dismiss}_virtual_keyboard()` method
that allows programmatically controlling keyboard visibility (without
interacting with GPUI focus). This allows for patterns like "pressing
button A focuses text field B, *and opens the keyboard*

This PR also fixes a bug in non-touch web that caused text fields to
lose IME mirror focus when they entered non-editable regions, which
could prevent keyboard input from being detected by GPUI properly.

---

Release Notes:

- N/A or Added/Fixed/Improved ...
This commit is contained in:
Cameron Mcloughlin authored and GitHub committed 2026-09-10 09:49:08 +00:00
1 parent 1d7ff1d2ee
commit 42626fe94e
9 files changed
+624 -157

No files matched your search

+10
View File
@@ -418,6 +418,16 @@ impl TestAppContext {
self.test_window(window_handle).simulate_resize(size);
}
/// Simulates visible viewport changes without resizing the window's layout area.
pub fn simulate_window_visual_viewport_change(
&self,
window_handle: AnyWindowHandle,
bounds: Bounds<Pixels>,
) {
self.test_window(window_handle)
.simulate_visual_viewport_change(bounds);
}
/// Simulates the window moving to a display with a different scale factor.
pub fn simulate_window_scale_factor_change(
&self,
+22 -2
View File
@@ -822,9 +822,9 @@ impl WindowInsets {
/// A change in the state of the focused text input.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub enum TextInputStateChange {
/// An editable element gained focus.
/// The window changed from having no active text input to having one.
FocusGained,
/// The focused editable element lost focus.
/// The window no longer has an active text input.
FocusLost,
/// The selection or caret moved
SelectionChanged,
@@ -838,6 +838,26 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
fn is_maximized(&self) -> bool;
fn window_bounds(&self) -> WindowBounds;
fn content_size(&self) -> Size<Pixels>;
/// Returns the visible viewport in logical pixels relative to the content origin.
///
/// This may be smaller or offset when a keyboard or zoom obscures content;
/// it must not change the full layout size returned by `content_size`.
/// Implementations should return a frame snapshot, not query platform layout here.
fn visual_viewport_bounds(&self) -> Bounds<Pixels> {
Bounds::new(Point::default(), self.content_size())
}
/// Registers a callback when visible geometry may have changed.
///
/// This requests a frame; backends can sample the new viewport and safe-area
/// geometry in `prepare_frame` rather than updating it inside the callback.
fn on_visual_viewport_changed(&self, _callback: Box<dyn FnMut()>) {}
/// Samples platform geometry before a draw, returning whether view caches must be invalidated.
///
/// Geometry getters must remain consistent throughout the ensuing draw.
/// Do not invoke callbacks here: GPUI is already updating this window.
fn prepare_frame(&self) -> bool {
false
}
fn resize(&mut self, size: Size<Pixels>);
fn scale_factor(&self) -> f32;
fn appearance(&self) -> WindowAppearance;
+72 -1
View File
@@ -4,7 +4,7 @@ use crate::{
PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point,
PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration,
TextInputStateChange, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds,
WindowControlArea, WindowParams,
WindowControlArea, WindowInsets, WindowParams,
};
use collections::HashMap;
use gpui_util::ResultExt as _;
@@ -36,6 +36,12 @@ pub(crate) struct TestWindowState {
active_status_change_callback: Option<Box<dyn FnMut(bool)>>,
hover_status_change_callback: Option<Box<dyn FnMut(bool)>>,
resize_callback: Option<Box<dyn FnMut(Size<Pixels>, f32)>>,
visual_viewport: Option<Bounds<Pixels>>,
visual_viewport_callback: Option<Box<dyn FnMut()>>,
insets: WindowInsets,
insets_callback: Option<Box<dyn FnMut(WindowInsets)>>,
virtual_keyboard_requests: usize,
virtual_keyboard_dismissals: usize,
moved_callback: Option<Box<dyn FnMut()>>,
appearance_change_callback: Option<Box<dyn FnMut()>>,
request_frame_callback: Option<Box<dyn FnMut(RequestFrameOptions)>>,
@@ -101,6 +107,12 @@ impl TestWindow {
active_status_change_callback: None,
hover_status_change_callback: None,
resize_callback: None,
visual_viewport: None,
visual_viewport_callback: None,
insets: WindowInsets::default(),
insets_callback: None,
virtual_keyboard_requests: 0,
virtual_keyboard_dismissals: 0,
moved_callback: None,
appearance_change_callback: None,
request_frame_callback: None,
@@ -141,6 +153,38 @@ impl TestWindow {
self.0.lock().frame_scheduled
}
pub fn simulate_visual_viewport_change(&self, bounds: Bounds<Pixels>) {
let callback = {
let mut state = self.0.lock();
state.visual_viewport = Some(bounds);
state.visual_viewport_callback.take()
};
if let Some(mut callback) = callback {
callback();
self.0.lock().visual_viewport_callback = Some(callback);
}
}
pub fn simulate_insets_change(&self, insets: WindowInsets) {
let callback = {
let mut state = self.0.lock();
state.insets = insets.clone();
state.insets_callback.take()
};
if let Some(mut callback) = callback {
callback(insets);
self.0.lock().insets_callback = Some(callback);
}
}
pub fn virtual_keyboard_requests(&self) -> usize {
self.0.lock().virtual_keyboard_requests
}
pub fn virtual_keyboard_dismissals(&self) -> usize {
self.0.lock().virtual_keyboard_dismissals
}
/// Every [`TextInputConfiguration`] forwarded to this window, in order.
pub fn text_input_configurations(&self) -> Vec<TextInputConfiguration> {
self.0.lock().text_input_configurations.clone()
@@ -232,6 +276,33 @@ impl TestWindow {
}
impl PlatformWindow for TestWindow {
fn visual_viewport_bounds(&self) -> Bounds<Pixels> {
let state = self.0.lock();
state
.visual_viewport
.unwrap_or_else(|| Bounds::new(Point::default(), state.bounds.size))
}
fn on_visual_viewport_changed(&self, callback: Box<dyn FnMut()>) {
self.0.lock().visual_viewport_callback = Some(callback);
}
fn insets(&self) -> WindowInsets {
self.0.lock().insets.clone()
}
fn on_insets_changed(&self, callback: Box<dyn FnMut(WindowInsets)>) {
self.0.lock().insets_callback = Some(callback);
}
fn show_soft_keyboard(&self) {
self.0.lock().virtual_keyboard_requests += 1;
}
fn hide_soft_keyboard(&self) {
self.0.lock().virtual_keyboard_dismissals += 1;
}
fn bounds(&self) -> Bounds<Pixels> {
self.0.lock().bounds
}
+131
View File
@@ -1818,6 +1818,22 @@ impl Window {
}
}));
invalidator.set_platform_waker(platform_window.frame_waker());
platform_window.on_visual_viewport_changed(Box::new({
let mut cx = cx.to_async();
move || {
handle
.update(&mut cx, |_, window, _| window.refresh())
.log_err();
}
}));
platform_window.on_insets_changed(Box::new({
let mut cx = cx.to_async();
move |_| {
handle
.update(&mut cx, |_, window, _| window.refresh())
.log_err();
}
}));
platform_window.on_resize(Box::new({
let mut cx = cx.to_async();
move |_, _| {
@@ -2671,6 +2687,46 @@ impl Window {
self.viewport_size
}
/// Returns the platform's visible viewport in window-local logical pixels.
///
/// Unlike `viewport_size`, this can shrink or move when the keyboard opens.
/// During drawing this is a consistent frame snapshot. Outside drawing it
/// reflects the latest platform sample, not a synchronous geometry query.
pub fn visual_viewport_bounds(&self) -> Bounds<Pixels> {
self.platform_window.visual_viewport_bounds()
}
/// Returns a conservative rectangle avoiding platform-known obscured content.
///
/// Intersects the visual viewport with the full layout area inset by system
/// safe areas and keyboard occlusion. Unknown overlays cannot be excluded.
pub fn fully_visible_bounds(&self) -> Bounds<Pixels> {
let insets = self.platform_window.insets().effective();
let viewport = self.viewport_size();
let left = insets.left.max(Pixels::ZERO).min(viewport.width);
let top = insets.top.max(Pixels::ZERO).min(viewport.height);
let right = (viewport.width - insets.right.max(Pixels::ZERO)).max(left);
let bottom = (viewport.height - insets.bottom.max(Pixels::ZERO)).max(top);
let safe_bounds = Bounds::from_corners(point(left, top), point(right, bottom));
let mut visible = safe_bounds.intersect(&self.visual_viewport_bounds());
visible.size.width = visible.size.width.max(Pixels::ZERO);
visible.size.height = visible.size.height.max(Pixels::ZERO);
visible
}
/// Requests the virtual keyboard for the currently focused text input.
///
/// Call from a user gesture on platforms that require one. The platform may
/// decline the request; this does not change focus or the layout viewport.
pub fn request_virtual_keyboard(&self) {
self.platform_window.show_soft_keyboard();
}
/// Requests dismissal of the virtual keyboard without changing GPUI focus.
pub fn dismiss_virtual_keyboard(&self) {
self.platform_window.hide_soft_keyboard();
}
/// Returns whether this window is focused by the operating system (receiving key events).
pub fn is_window_active(&self) -> bool {
self.active.get()
@@ -3042,6 +3098,9 @@ impl Window {
// This ensures that multiple test Apps have isolated arenas.
let arena_scope = ElementArenaScope::enter(&cx.element_arena);
if self.platform_window.prepare_frame() {
self.refresh();
}
self.invalidate_entities();
cx.entities.clear_accessed();
debug_assert!(self.rendered_entity_stack.is_empty());
@@ -7386,6 +7445,78 @@ mod tests {
TouchId, TouchPhase, Window, WindowAppearance, WindowOptions, canvas, div, point, px, size,
};
#[gpui::test]
fn test_fully_visible_bounds_preserve_layout_viewport(cx: &mut TestAppContext) {
let window = cx.add_window(|_, _| EmptyView);
let mut platform_window = cx.test_window(window.into());
platform_window.simulate_resize(size(px(400.), px(800.)));
window
.update(cx, |_, window, _| {
assert_eq!(
window.visual_viewport_bounds(),
Bounds::new(Point::default(), size(px(400.), px(800.)))
);
assert_eq!(
window.fully_visible_bounds(),
window.visual_viewport_bounds()
);
})
.unwrap();
platform_window.simulate_frame_request(RequestFrameOptions::default());
let wakes = platform_window.frame_wake_count();
let visual_bounds = Bounds::new(point(px(10.), px(40.)), size(px(380.), px(460.)));
platform_window.simulate_visual_viewport_change(visual_bounds);
assert!(platform_window.frame_wake_count() > wakes);
platform_window.simulate_frame_request(RequestFrameOptions::default());
let wakes = platform_window.frame_wake_count();
platform_window.simulate_insets_change(crate::WindowInsets {
safe_area: crate::Edges {
top: px(60.),
right: px(20.),
bottom: px(30.),
left: px(-10.),
},
ime: crate::Edges {
bottom: px(350.),
..Default::default()
},
});
assert!(platform_window.frame_wake_count() > wakes);
window
.update(cx, |_, window, _| {
assert_eq!(window.viewport_size(), size(px(400.), px(800.)));
assert_eq!(window.visual_viewport_bounds(), visual_bounds);
assert_eq!(
window.fully_visible_bounds(),
Bounds::new(point(px(10.), px(60.)), size(px(370.), px(390.)))
);
window.request_virtual_keyboard();
window.dismiss_virtual_keyboard();
assert_eq!(window.viewport_size(), size(px(400.), px(800.)));
})
.unwrap();
assert_eq!(platform_window.virtual_keyboard_requests(), 1);
assert_eq!(platform_window.virtual_keyboard_dismissals(), 1);
platform_window.simulate_insets_change(crate::WindowInsets {
safe_area: crate::Edges {
top: px(900.),
left: px(500.),
..Default::default()
},
..Default::default()
});
window
.update(cx, |_, window, _| {
assert_eq!(
window.fully_visible_bounds().size,
size(Pixels::ZERO, Pixels::ZERO)
);
})
.unwrap();
}
struct EmptyView;
impl Render for EmptyView {
+102 -127
View File
@@ -172,10 +172,29 @@ impl WebWindowInner {
handles.extend(self.register_visibility_change());
handles.extend(self.register_appearance_change());
handles.extend(self.register_fullscreen_change());
handles.extend(self.register_viewport_changes());
WebEventListeners { _handles: handles }
}
fn register_viewport_changes(self: &Rc<Self>) -> Vec<EventListenerHandle> {
let mut targets: Vec<web_sys::EventTarget> = vec![self.browser_window.clone().into()];
if let Some(viewport) = self.browser_window.visual_viewport() {
targets.push(viewport.into());
}
targets
.into_iter()
.flat_map(|target| {
["resize", "scroll"].map(|event_name| {
let this = Rc::clone(self);
EventListenerHandle::add(&target, event_name, move |_| {
this.notify_viewport_changed()
})
})
})
.collect()
}
fn listen(
self: &Rc<Self>,
event_name: &'static str,
@@ -233,6 +252,13 @@ impl WebWindowInner {
this.canvas.set_pointer_capture(event.pointer_id()).ok();
if pointer_type == "touch" {
// Android can reopen a manually dismissed keyboard on the
// next touch even without another focus() call. Disarm the
// still-focused mirror before the browser handles that
// gesture; only an editable tap may enable its keyboard again.
if this.keyboard_likely_dismissed() {
this.ime_mirror.set_virtual_keyboard_enabled(false);
}
let Some(touch_id) = this.touch_ids.borrow_mut().start(event.pointer_id()) else {
log::error!("exhausted touch identifiers");
return;
@@ -292,11 +318,6 @@ 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| {
@@ -317,7 +338,6 @@ 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.
@@ -331,22 +351,19 @@ impl WebWindowInner {
// A keyboard opening or closing mid-gesture reflows the
// layout, so the release position no longer refers to the
// content the user aimed at (a tap that summoned the keyboard
// often ends up below the shrunken layout, which would
// immediately dismiss it again). Skip the sync then, and for
// anything that wasn't a tap: pans and flings must not move
// keyboard or IME focus at all.
// content the user aimed at. Do not summon the keyboard
// from that stale position, or from pans and flings.
let viewport_stable = this.gesture_start_visual_viewport_height.get()
== this.visual_viewport_height();
if completes_tap && viewport_stable {
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));
}
// A consumed tap may navigate to an editor under this same
// coordinate. Do not reinterpret that navigation as editing.
// Controls that intend typing can explicitly request a keyboard.
if completes_tap
&& viewport_stable
&& dispatch_result.is_some_and(|result| !result.default_prevented)
&& this.pointer_targets_text_input(position)
{
this.show_virtual_keyboard();
}
this.schedule_ime_mirror_sync();
return;
@@ -383,44 +400,41 @@ impl WebWindowInner {
.map_or(0.0, |viewport| viewport.height() * viewport.scale())
}
/// Whether the software keyboard is likely hidden — a heuristic, since
/// no cross-browser keyboard-visibility signal exists. It infers from
/// the visual viewport: a shown keyboard shrinks its height well below
/// the greatest height seen at the current width (the width only changes
/// on rotation, which restarts the calibration). `window.innerHeight`
/// can't serve as the reference because Android shrinks it along with
/// the keyboard. Unknown states err toward "visible" so ordinary
/// editable taps don't gratuitously restart the IME session.
/// Infers dismissal only when the visual viewport fills the layout viewport.
///
/// Restricted to coarse-pointer environments: elsewhere (desktop
/// browsers, including touchscreen laptops) viewport height tracks
/// user window resizes rather than a software keyboard, so the
/// calibration would misfire. Split-screen resizes on mobile can still
/// fool it; tracking `visualViewport` resize events around focus
/// transitions would be sturdier.
/// This fallback requires `interactive-widget=resizes-visual` (the modern
/// browser default). It deliberately makes no inference while zoomed, or
/// when the host opts into another resize policy. Floating keyboards can
/// still overlay both viewports; this is not authoritative keyboard state.
fn keyboard_likely_dismissed(&self) -> bool {
let coarse_pointer = self
.browser_window
.match_media("(pointer: coarse)")
.ok()
.flatten()
.is_some_and(|media_query_list| media_query_list.matches());
if !coarse_pointer {
if !self.touch_input {
return false;
}
let Some(viewport) = self.browser_window.visual_viewport() else {
return false;
};
let width = viewport.width() * viewport.scale();
let height = viewport.height() * viewport.scale();
let (probe_width, probe_height) = self.visual_viewport_probe.get();
let max_height = if width == probe_width {
probe_height.max(height)
} else {
height
let Some(document) = self.browser_window.document() else {
return false;
};
self.visual_viewport_probe.set((width, max_height));
height >= max_height * 0.85
if let Ok(Some(meta)) = document.query_selector("meta[name=viewport]")
&& let Some(content) = meta.get_attribute("content")
&& content.split(',').any(|directive| {
directive.split_once('=').is_some_and(|(key, value)| {
key.trim().eq_ignore_ascii_case("interactive-widget")
&& !value.trim().eq_ignore_ascii_case("resizes-visual")
})
})
{
return false;
}
let Some(root) = document.document_element() else {
return false;
};
(viewport.scale() - 1.).abs() < 0.001
&& viewport.offset_top().abs() < 1.
&& viewport.offset_left().abs() < 1.
&& (viewport.height() - root.client_height() as f64).abs() < 1.
&& (viewport.width() - root.client_width() as f64).abs() < 1.
}
/// The browser or OS took over the pointer (native scrolling, a system
@@ -472,17 +486,17 @@ impl WebWindowInner {
/// focused from within a user gesture, so this runs while the tap's
/// `pointerup` is still on the stack (by which point GPUI has usually
/// painted a frame since the `MouseDown`, so the input handler reflects
/// the tap's focus change). `readOnly` suppresses the keyboard while
/// keeping the hidden input available to the IME. Leaving it blurred after
/// a non-editable tap lets the next editable tap establish a new input
/// session instead of relying on a same-task blur/focus cycle, which iOS
/// may coalesce.
/// the tap's focus change). This also handles an editable tap after manual
/// dismissal, when editability stayed true and no focus transition fires.
///
/// We don't use `navigator.virtualKeyboard` here because it's
/// Chromium-only.
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);
pub(crate) fn show_virtual_keyboard(self: &Rc<Self>) {
if !self.touch_input {
return;
}
let was_enabled = self.ime_mirror.virtual_keyboard_enabled();
self.ime_mirror.set_virtual_keyboard_enabled(true);
// Trigger a focus event only when the keyboard actually needs
// summoning. Cycling focus on every tap would restart the IME
// connection right as the keyboard reads the tapped caret's context,
@@ -490,41 +504,38 @@ impl WebWindowInner {
// element is a no-op, so a dismissed keyboard would otherwise never
// return for taps that stay within editable content: detect that
// through the visual viewport and force a fresh focus event.
let editable_needs_focus_event = editable
&& (!was_editable || !self.ime_mirror.is_focused() || self.keyboard_likely_dismissed());
if editable_needs_focus_event || (!editable && was_editable) {
self.suppress_focus_status_events.set(true);
if editable {
// A same-task blur/focus cycle may be coalesced by iOS, but
// this branch only runs when the keyboard is already gone,
// so a coalesced cycle loses nothing.
if self.ime_mirror.is_focused() {
self.ime_mirror.blur();
}
self.ime_mirror.focus();
} else {
self.ime_mirror.blur();
}
self.suppress_focus_status_events.set(false);
if !was_enabled || !self.ime_mirror.is_focused() || self.keyboard_likely_dismissed() {
// A same-task blur/focus cycle may be coalesced by iOS, but
// this branch only runs when the keyboard is already gone,
// so a coalesced cycle loses nothing.
self.focus_ime_mirror();
}
}
if editable {
let callback = wasm_bindgen::closure::Closure::once_into_js({
let this = Rc::clone(self);
move || {
this.state.borrow_mut().is_active = true;
this.with_callback(
|callbacks| &mut callbacks.active_status_change,
|callback| callback(true),
);
}
});
if let Err(error) = self
.browser_window
.set_timeout_with_callback(callback.unchecked_ref())
{
log::warn!("failed to defer web window activation: {error:?}");
}
/// Restarts DOM input without synchronously re-entering GPUI activation.
pub(crate) fn focus_ime_mirror(self: &Rc<Self>) {
self.suppress_focus_status_events.set(true);
if self.ime_mirror.is_focused() {
self.ime_mirror.blur();
}
self.ime_mirror.focus();
self.suppress_focus_status_events.set(false);
let callback = wasm_bindgen::closure::Closure::once_into_js({
let this = Rc::clone(self);
move || {
this.state.borrow_mut().is_active = true;
this.with_callback(
|callbacks| &mut callbacks.active_status_change,
|callback| callback(true),
);
}
});
if let Err(error) = self
.browser_window
.set_timeout_with_callback(callback.unchecked_ref())
{
log::warn!("failed to defer web window activation: {error:?}");
}
}
@@ -1281,16 +1292,6 @@ 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();
@@ -1444,30 +1445,4 @@ mod tests {
assert_ne!(reused, first);
assert_ne!(reused, concurrent);
}
#[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),));
}
}
+1
View File
@@ -13,6 +13,7 @@ mod ime_mirror;
mod keyboard;
mod logging;
mod platform;
mod viewport;
mod window;
pub use dispatcher::WebDispatcher;
+19 -16
View File
@@ -52,7 +52,7 @@ const MIN_EDGE_CHARS: usize = 64;
///
/// The element and every value/selection write on it are private to this
/// module; other code interacts through read accessors, focus and
/// read-only control, and [`ImeMirror::schedule_sync`].
/// virtual-keyboard control, and [`ImeMirror::schedule_sync`].
pub(crate) struct ImeMirror {
element: web_sys::HtmlTextAreaElement,
/// The mirror text most recently synced to (or observed in) the hidden
@@ -83,19 +83,11 @@ pub(crate) struct ImeMirror {
selection_import_rejected: Cell<bool>,
}
/// Whether the device's primary pointer is coarse (a touch screen). The
/// distinction drives virtual-keyboard policy: touch-first browsers summon
/// the keyboard for any focused editable element on a user gesture.
fn primary_pointer_is_coarse() -> bool {
web_sys::window()
.and_then(|window| window.match_media("(pointer: coarse)").ok().flatten())
.is_some_and(|media_query_list| media_query_list.matches())
}
impl ImeMirror {
pub(crate) fn new(
document: &web_sys::Document,
body: &web_sys::HtmlElement,
touch_input: bool,
) -> anyhow::Result<Self> {
// A textarea rather than an input: single-line inputs silently strip
// newlines from assigned values, which would make the mirror text
@@ -122,10 +114,12 @@ impl ImeMirror {
// The element must stay focused to receive hardware-key and IME
// events, but on touch-first devices a focused *editable* element
// invites the browser to summon the virtual keyboard on the next
// user gesture — including a scroll. Start read-only there; only a
// recognized tap on text input lifts it (`sync_virtual_keyboard`).
if primary_pointer_is_coarse() {
element.set_read_only(true);
// user gesture — including a scroll. Suppress the virtual keyboard
// until an editable tap, without disabling hardware IME composition.
if touch_input {
element
.set_attribute("inputmode", "none")
.map_err(|error| anyhow::anyhow!("Failed to configure input mode: {error:?}"))?;
}
let this = Self {
@@ -204,14 +198,23 @@ impl ImeMirror {
self.element.blur().ok();
}
pub(crate) fn read_only(&self) -> bool {
self.element.read_only()
pub(crate) fn virtual_keyboard_enabled(&self) -> bool {
self.element.get_attribute("inputmode").as_deref() != Some("none")
}
pub(crate) fn set_read_only(&self, read_only: bool) {
self.element.set_read_only(read_only);
}
pub(crate) fn set_virtual_keyboard_enabled(&self, enabled: bool) {
if let Err(error) = self
.element
.set_attribute("inputmode", if enabled { "text" } else { "none" })
{
log::warn!("Failed to configure input mode: {error:?}");
}
}
pub(crate) fn remove(&self) {
let element: &web_sys::Element = self.element.as_ref();
element.remove();
+126
View File
@@ -0,0 +1,126 @@
//! Browser viewport and safe-area frame snapshots in canvas coordinates.
//!
//! Browser geometry is sampled before rendering, never by geometry getters.
//! Safe-area padding is resolved by CSS so display cutouts use the browser's
//! own environment values rather than device-specific assumptions.
use gpui::{Bounds, Edges, Pixels, WindowInsets, point, px, size};
use wasm_bindgen::JsCast;
pub(crate) struct WebViewport {
safe_area_probes: [web_sys::HtmlElement; 4],
pub(crate) visible_bounds: Bounds<Pixels>,
pub(crate) insets: WindowInsets,
}
impl WebViewport {
pub(crate) fn new(document: &web_sys::Document) -> anyhow::Result<Self> {
let [top, right, bottom, left] =
["top", "right", "bottom", "left"].map(|edge| safe_area_probe(document, edge));
let viewport = Self {
safe_area_probes: [top?, right?, bottom?, left?],
visible_bounds: Bounds::default(),
insets: WindowInsets::default(),
};
let body = document
.body()
.ok_or_else(|| anyhow::anyhow!("Missing document body"))?;
for probe in &viewport.safe_area_probes {
body.append_child(probe)
.map_err(|error| anyhow::anyhow!("Failed to attach safe-area probe: {error:?}"))?;
}
Ok(viewport)
}
pub(crate) fn observe_safe_area(&self, observer: &web_sys::ResizeObserver) {
// Separate boxes detect redistribution between edges even when the
// total inset, and therefore a single combined probe's size, is unchanged.
for probe in &self.safe_area_probes {
observer.observe(probe);
}
}
pub(crate) fn update(
&mut self,
window: &web_sys::Window,
canvas: &web_sys::HtmlCanvasElement,
) -> anyhow::Result<bool> {
let canvas_bounds = canvas.get_bounding_client_rect();
let document = window
.document()
.ok_or_else(|| anyhow::anyhow!("Missing document"))?;
let root = document
.document_element()
.ok_or_else(|| anyhow::anyhow!("Missing root"))?;
let layout_width = root.client_width() as f64;
let layout_height = root.client_height() as f64;
let (left, top, width, height) = match window.visual_viewport() {
Some(viewport) => (
viewport.offset_left(),
viewport.offset_top(),
viewport.width(),
viewport.height(),
),
None => (0., 0., layout_width, layout_height),
};
// VisualViewport offsets and getBoundingClientRect share layout CSS
// coordinates. Multiplying by the pinch scale here would incorrectly
// expand the visible rectangle back to its unzoomed size.
let x = (left - canvas_bounds.left()).clamp(0., canvas_bounds.width());
let y = (top - canvas_bounds.top()).clamp(0., canvas_bounds.height());
let right = (left + width - canvas_bounds.left()).clamp(x, canvas_bounds.width());
let bottom = (top + height - canvas_bounds.top()).clamp(y, canvas_bounds.height());
let visible_bounds = Bounds::new(
point(px(x as f32), px(y as f32)),
size(px((right - x) as f32), px((bottom - y) as f32)),
);
let [top, right, bottom, left] = self
.safe_area_probes
.each_ref()
.map(|probe| probe.get_bounding_client_rect().height());
let safe_area = Edges {
top: px((top - canvas_bounds.top()).max(0.) as f32),
right: px((canvas_bounds.right() - layout_width + right).max(0.) as f32),
bottom: px((canvas_bounds.bottom() - layout_height + bottom).max(0.) as f32),
left: px((left - canvas_bounds.left()).max(0.) as f32),
};
let insets = WindowInsets {
safe_area,
..WindowInsets::default()
};
let changed = self.visible_bounds != visible_bounds || self.insets != insets;
self.visible_bounds = visible_bounds;
self.insets = insets;
Ok(changed)
}
}
impl Drop for WebViewport {
fn drop(&mut self) {
for probe in &self.safe_area_probes {
probe.remove();
}
}
}
fn safe_area_probe(
document: &web_sys::Document,
edge: &str,
) -> anyhow::Result<web_sys::HtmlElement> {
let probe = document
.create_element("div")
.map_err(|error| anyhow::anyhow!("Failed to create safe-area probe: {error:?}"))?
.dyn_into::<web_sys::HtmlElement>()
.map_err(|error| anyhow::anyhow!("Invalid safe-area probe element: {error:?}"))?;
probe
.set_attribute(
"style",
&format!(
"position:fixed;left:0;top:0;width:0;padding:0;border:0;visibility:hidden;\
pointer-events:none;height:env(safe-area-inset-{edge},0px)"
),
)
.map_err(|error| anyhow::anyhow!("Failed to style safe-area probe: {error:?}"))?;
Ok(probe)
}
+141 -11
View File
@@ -4,6 +4,7 @@ use crate::events::{
};
use crate::ime_mirror::ImeMirror;
use crate::platform::WebWindowLifecycle;
use crate::viewport::WebViewport;
use std::sync::Arc;
use std::{cell::Cell, cell::RefCell, rc::Rc};
@@ -13,7 +14,7 @@ use gpui::{
PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions,
ResizeEdge, Scene, Size, TextInputConfiguration, TextInputStateChange, WindowAppearance,
WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations,
WindowParams, px,
WindowInsets, WindowParams, px,
};
use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig, wgpu};
use wasm_bindgen::prelude::*;
@@ -25,6 +26,7 @@ pub(crate) struct WebWindowCallbacks {
pub(crate) active_status_change: Option<Box<dyn FnMut(bool)>>,
pub(crate) hover_status_change: Option<Box<dyn FnMut(bool)>>,
pub(crate) resize: Option<Box<dyn FnMut(Size<Pixels>, f32)>>,
pub(crate) visual_viewport_changed: Option<Box<dyn FnMut()>>,
pub(crate) moved: Option<Box<dyn FnMut()>>,
pub(crate) should_close: Option<Box<dyn FnMut() -> bool>>,
pub(crate) close: Option<Box<dyn FnOnce()>>,
@@ -51,8 +53,10 @@ pub(crate) struct WebWindowInner {
pub(crate) browser_window: web_sys::Window,
pub(crate) canvas: web_sys::HtmlCanvasElement,
pub(crate) ime_mirror: ImeMirror,
pub(crate) viewport: RefCell<WebViewport>,
pub(crate) has_device_pixel_support: bool,
pub(crate) is_mac: bool,
pub(crate) touch_input: bool,
pub(crate) state: RefCell<WebWindowMutableState>,
pub(crate) callbacks: RefCell<WebWindowCallbacks>,
pub(crate) click_state: RefCell<ClickState>,
@@ -61,17 +65,12 @@ pub(crate) struct WebWindowInner {
pub(crate) last_physical_size: Cell<(u32, u32)>,
pub(crate) notify_scale: Cell<bool>,
pub(crate) is_composing: Cell<bool>,
/// Set while `sync_virtual_keyboard` blur/focus-cycles the hidden input.
/// Set while `show_virtual_keyboard` blur/focus-cycles the hidden input.
/// The cycle is a keyboard-visibility hint, not a real activity change;
/// letting the focus/blur listeners report it would re-enter GPUI
/// synchronously from inside an input dispatch, and a `RefCell`
/// double-borrow panic on wasm never unwinds, wedging the app.
pub(crate) suppress_focus_status_events: Cell<bool>,
/// The visual viewport's width and greatest height seen at that width,
/// in layout pixels. The keyboard-visibility probe compares the current
/// height against this maximum; the width detects rotation, which must
/// restart the calibration.
pub(crate) visual_viewport_probe: Cell<(f64, f64)>,
/// The visual viewport height when the current pointer gesture began.
/// A mid-gesture change means the software keyboard opened or closed and
/// reflowed the layout, so the release position no longer refers to what
@@ -97,6 +96,8 @@ pub struct WebWindow {
_raf_closure: Closure<dyn FnMut()>,
_resize_observer: Option<web_sys::ResizeObserver>,
_resize_observer_closure: Closure<dyn FnMut(js_sys::Array)>,
_safe_area_observer: Option<web_sys::ResizeObserver>,
_safe_area_observer_closure: Closure<dyn FnMut(js_sys::Array)>,
_event_listeners: WebEventListeners,
}
@@ -168,7 +169,14 @@ impl WebWindow {
};
let renderer = WgpuRenderer::new_from_surface(context, surface, renderer_config)?;
let ime_mirror = ImeMirror::new(&document, &body)?;
let touch_input = browser_window
.match_media("(pointer: coarse)")
.ok()
.flatten()
.is_some_and(|query| query.matches());
let ime_mirror = ImeMirror::new(&document, &body, touch_input)?;
let mut viewport = WebViewport::new(&document)?;
viewport.update(&browser_window, &canvas)?;
let display: Rc<dyn PlatformDisplay> = Rc::new(WebDisplay::new(browser_window.clone()));
@@ -198,8 +206,10 @@ impl WebWindow {
browser_window,
canvas,
ime_mirror,
viewport: RefCell::new(viewport),
has_device_pixel_support,
is_mac,
touch_input,
state: RefCell::new(mutable_state),
callbacks: RefCell::new(WebWindowCallbacks::default()),
click_state: RefCell::new(ClickState::default()),
@@ -209,7 +219,6 @@ impl WebWindow {
notify_scale: Cell::new(false),
is_composing: Cell::new(false),
suppress_focus_status_events: Cell::new(false),
visual_viewport_probe: Cell::new((0.0, 0.0)),
gesture_start_visual_viewport_height: Cell::new(0.0),
touch_tap_candidate: Cell::new(None),
mql_handle: RefCell::new(None),
@@ -231,6 +240,26 @@ impl WebWindow {
}
let event_listeners = inner.register_event_listeners();
let safe_area_observer_closure = Closure::wrap(Box::new({
let inner = Rc::downgrade(&inner);
move |_: js_sys::Array| {
if let Some(inner) = inner.upgrade() {
inner.notify_viewport_changed();
}
}
}) as Box<dyn FnMut(js_sys::Array)>);
let safe_area_observer =
match web_sys::ResizeObserver::new(safe_area_observer_closure.as_ref().unchecked_ref())
{
Ok(observer) => {
inner.viewport.borrow().observe_safe_area(&observer);
Some(observer)
}
Err(error) => {
log::warn!("Failed to observe safe area: {error:?}");
None
}
};
Ok(Self {
inner,
@@ -241,6 +270,8 @@ impl WebWindow {
_resize_observer: resize_observer,
_resize_observer_closure: resize_observer_closure,
_event_listeners: event_listeners,
_safe_area_observer: safe_area_observer,
_safe_area_observer_closure: safe_area_observer_closure,
})
}
@@ -362,6 +393,27 @@ impl WebWindow {
}
impl WebWindowInner {
fn sample_viewport(&self) -> bool {
match self
.viewport
.borrow_mut()
.update(&self.browser_window, &self.canvas)
{
Ok(changed) => changed,
Err(error) => {
log::warn!("Failed to update browser viewport: {error:#}");
false
}
}
}
pub(crate) fn notify_viewport_changed(&self) {
self.with_callback(
|callbacks| &mut callbacks.visual_viewport_changed,
|callback| callback(),
);
}
/// Invokes a registered callback with take/call/restore semantics.
///
/// The callback is removed from the slot for the duration of the call, so
@@ -555,6 +607,9 @@ impl Drop for WebWindow {
if let Some(ref observer) = self._resize_observer {
observer.disconnect();
}
if let Some(observer) = &self._safe_area_observer {
observer.disconnect();
}
// The DPR media-query closure captures an `Rc<WebWindowInner>` and is
// stored inside the inner itself, forming a reference cycle; take it
@@ -695,10 +750,85 @@ impl PlatformWindow for WebWindow {
self.inner.ime_mirror.apply_configuration(&configuration);
}
fn show_soft_keyboard(&self) {
self.inner.show_virtual_keyboard();
}
fn hide_soft_keyboard(&self) {
if !self.inner.touch_input {
return;
}
self.inner.ime_mirror.set_virtual_keyboard_enabled(false);
// Restart the browser input session without a software keyboard, but
// keep receiving hardware keys that can move the caret back into an
// editable region. GPUI focus, not this DOM element, owns their target.
self.inner.suppress_focus_status_events.set(true);
self.inner.ime_mirror.blur();
self.inner.suppress_focus_status_events.set(false);
// Chrome can coalesce a same-task blur/focus and leave the keyboard
// visible. Restore the hardware-key receiver after this task instead.
let callback = Closure::once_into_js({
let inner = Rc::downgrade(&self.inner);
move || {
if let Some(inner) = inner.upgrade()
&& !inner.ime_mirror.virtual_keyboard_enabled()
&& inner.state.borrow().is_active
{
inner.suppress_focus_status_events.set(true);
inner.ime_mirror.focus();
inner.suppress_focus_status_events.set(false);
}
}
});
if let Err(error) = self
.inner
.browser_window
.set_timeout_with_callback(callback.unchecked_ref())
{
log::warn!("failed to restore keyboard event receiver: {error:?}");
}
}
fn visual_viewport_bounds(&self) -> Bounds<Pixels> {
self.inner.viewport.borrow().visible_bounds
}
fn prepare_frame(&self) -> bool {
self.inner.sample_viewport()
}
fn on_visual_viewport_changed(&self, callback: Box<dyn FnMut()>) {
self.inner.callbacks.borrow_mut().visual_viewport_changed = Some(callback);
}
fn insets(&self) -> WindowInsets {
self.inner.viewport.borrow().insets.clone()
}
fn text_input_state_changed(&self, change: TextInputStateChange) {
// React to editability transitions, not each frame: manually dismissing
// the keyboard must remain effective until another editing gesture.
// On desktop, the DOM input remains the keyboard event receiver even
// when GPUI focuses a read-only surface. Blurring it would also disable
// hardware shortcuts, unlike a native window.
if self.inner.touch_input {
match change {
TextInputStateChange::FocusGained => self.inner.show_virtual_keyboard(),
TextInputStateChange::FocusLost => self.hide_soft_keyboard(),
TextInputStateChange::SelectionChanged | TextInputStateChange::ContentChanged => {}
}
return;
}
match change {
TextInputStateChange::FocusGained => self.inner.sync_virtual_keyboard(true),
TextInputStateChange::FocusLost => self.inner.sync_virtual_keyboard(false),
TextInputStateChange::FocusGained => {
self.inner.ime_mirror.set_read_only(false);
if !self.inner.ime_mirror.is_focused() {
self.inner.focus_ime_mirror();
}
}
TextInputStateChange::FocusLost => {
self.inner.ime_mirror.set_read_only(true);
}
TextInputStateChange::SelectionChanged | TextInputStateChange::ContentChanged => {}
}
}