diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index b062ddf65a..5722d69e42 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -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, + ) { + 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, diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index aff37c0981..935fdaf223 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -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; + /// 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 { + 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) {} + /// 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); fn scale_factor(&self) -> f32; fn appearance(&self) -> WindowAppearance; diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 5a07bc5c01..bfbd9c00eb 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -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>, hover_status_change_callback: Option>, resize_callback: Option, f32)>>, + visual_viewport: Option>, + visual_viewport_callback: Option>, + insets: WindowInsets, + insets_callback: Option>, + virtual_keyboard_requests: usize, + virtual_keyboard_dismissals: usize, moved_callback: Option>, appearance_change_callback: Option>, request_frame_callback: Option>, @@ -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) { + 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 { self.0.lock().text_input_configurations.clone() @@ -232,6 +276,33 @@ impl TestWindow { } impl PlatformWindow for TestWindow { + fn visual_viewport_bounds(&self) -> Bounds { + 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) { + self.0.lock().visual_viewport_callback = Some(callback); + } + + fn insets(&self) -> WindowInsets { + self.0.lock().insets.clone() + } + + fn on_insets_changed(&self, callback: Box) { + 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 { self.0.lock().bounds } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 4ec79254ef..879b702e1e 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -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 { + 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 { + 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 { diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index a6a1b2f4be..bc854cca7d 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -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) -> Vec { + let mut targets: Vec = 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, 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) -> 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, 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) { + 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.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, -) -> 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),)); - } } diff --git a/crates/gpui_web/src/gpui_web.rs b/crates/gpui_web/src/gpui_web.rs index 6084804320..c9411f7e42 100644 --- a/crates/gpui_web/src/gpui_web.rs +++ b/crates/gpui_web/src/gpui_web.rs @@ -13,6 +13,7 @@ mod ime_mirror; mod keyboard; mod logging; mod platform; +mod viewport; mod window; pub use dispatcher::WebDispatcher; diff --git a/crates/gpui_web/src/ime_mirror.rs b/crates/gpui_web/src/ime_mirror.rs index 75ef807113..b53792aee2 100644 --- a/crates/gpui_web/src/ime_mirror.rs +++ b/crates/gpui_web/src/ime_mirror.rs @@ -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, } -/// 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 { // 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(); diff --git a/crates/gpui_web/src/viewport.rs b/crates/gpui_web/src/viewport.rs new file mode 100644 index 0000000000..1e6582af13 --- /dev/null +++ b/crates/gpui_web/src/viewport.rs @@ -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, + pub(crate) insets: WindowInsets, +} + +impl WebViewport { + pub(crate) fn new(document: &web_sys::Document) -> anyhow::Result { + 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 { + 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 { + let probe = document + .create_element("div") + .map_err(|error| anyhow::anyhow!("Failed to create safe-area probe: {error:?}"))? + .dyn_into::() + .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) +} diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index 06c021bd40..61b5a7c651 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -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>, pub(crate) hover_status_change: Option>, pub(crate) resize: Option, f32)>>, + pub(crate) visual_viewport_changed: Option>, pub(crate) moved: Option>, pub(crate) should_close: Option bool>>, pub(crate) close: Option>, @@ -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, pub(crate) has_device_pixel_support: bool, pub(crate) is_mac: bool, + pub(crate) touch_input: bool, pub(crate) state: RefCell, pub(crate) callbacks: RefCell, pub(crate) click_state: RefCell, @@ -61,17 +65,12 @@ pub(crate) struct WebWindowInner { pub(crate) last_physical_size: Cell<(u32, u32)>, pub(crate) notify_scale: Cell, pub(crate) is_composing: Cell, - /// 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, - /// 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, _resize_observer: Option, _resize_observer_closure: Closure, + _safe_area_observer: Option, + _safe_area_observer_closure: Closure, _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 = 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); + 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` 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 { + self.inner.viewport.borrow().visible_bounds + } + + fn prepare_frame(&self) -> bool { + self.inner.sample_viewport() + } + + fn on_visual_viewport_changed(&self, callback: Box) { + 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 => {} } }