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:
1 parent
1d7ff1d2ee
commit
42626fe94e
9 files changed
+624
-157
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod ime_mirror;
|
||||
mod keyboard;
|
||||
mod logging;
|
||||
mod platform;
|
||||
mod viewport;
|
||||
mod window;
|
||||
|
||||
pub use dispatcher::WebDispatcher;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user