From a3c4abf07ce054cda651aa7d94b33f5f209fca4b Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 26 Aug 2026 17:57:23 +0000 Subject: [PATCH] gpui: Text input configuration and web implementation (#63264) Allows setting various attributes for the underlying mirror textarea on the web. Allows GPUI app authors to specify things like "this element should be auto-corrected" --- Release Notes: - N/A or Added/Fixed/Improved ... --- crates/gpui/src/input.rs | 207 +++++++++++++++++++++++- crates/gpui/src/platform.rs | 92 +++++++++++ crates/gpui/src/platform/test/window.rs | 15 +- crates/gpui/src/window.rs | 36 ++++- crates/gpui_web/src/events.rs | 96 +++++++++-- crates/gpui_web/src/ime_mirror.rs | 65 +++++++- crates/gpui_web/src/window.rs | 20 ++- 7 files changed, 502 insertions(+), 29 deletions(-) diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index 6de5719c69..70ac06e659 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -1,5 +1,6 @@ use crate::{ - App, Bounds, ClipboardItem, Context, Entity, InputHandler, Pixels, UTF16Selection, Window, + App, Bounds, ClipboardItem, Context, Entity, InputHandler, Pixels, TextInputConfiguration, + UTF16Selection, Window, }; use std::ops::Range; @@ -102,6 +103,15 @@ pub trait EntityInputHandler: 'static + Sized { fn accepts_text_input(&self, _window: &mut Window, _cx: &mut Context) -> bool { true } + + /// See [`InputHandler::text_input_configuration`] for details + fn text_input_configuration( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> TextInputConfiguration { + TextInputConfiguration::default() + } } /// The canonical implementation of [`crate::PlatformInputHandler`]. Call [`Window::handle_input`] @@ -244,4 +254,199 @@ impl InputHandler for ElementInputHandler { self.view .update(cx, |view, cx| view.accepts_text_input(window, cx)) } + + fn text_input_configuration( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> TextInputConfiguration { + self.view + .update(cx, |view, cx| view.text_input_configuration(window, cx)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + AnyWindowHandle, AppContext as _, FocusHandle, InteractiveElement as _, IntoElement, + ParentElement as _, Render, Styled as _, TestAppContext, TextInputAction, canvas, div, + }; + + #[gpui::test] + fn text_input_configuration_forwarded_only_on_change(cx: &mut TestAppContext) { + let custom = TextInputConfiguration { + autocorrect: true, + input_action: TextInputAction::Send, + ..Default::default() + }; + let window = cx.add_window({ + let custom = custom.clone(); + move |_, cx| ConfigurationTestView { + focus_handle: cx.focus_handle(), + configuration: custom, + } + }); + let view = window.root(cx).unwrap(); + let test_window = cx.test_window(window.into()); + let window = AnyWindowHandle::from(window); + let draw = |cx: &mut TestAppContext| { + cx.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + }; + + // Nothing is focused, so the platform learns the default configuration. + draw(cx); + assert_eq!( + test_window.text_input_configurations(), + vec![TextInputConfiguration::default()] + ); + + // Focusing the view routes its configuration to the platform. + cx.update_window(window, |_, window, cx| { + let focus_handle = view.read(cx).focus_handle.clone(); + window.focus(&focus_handle, cx); + }) + .unwrap(); + draw(cx); + assert_eq!( + test_window.text_input_configurations(), + vec![TextInputConfiguration::default(), custom.clone()] + ); + + // Redrawing without a change forwards nothing. + draw(cx); + assert_eq!(test_window.text_input_configurations().len(), 2); + + // Changing the configuration forwards the new value. + let updated = TextInputConfiguration { + suggestions: true, + ..custom + }; + view.update(cx, { + let updated = updated.clone(); + |view, cx| { + view.configuration = updated; + cx.notify(); + } + }); + draw(cx); + assert_eq!( + test_window.text_input_configurations().last(), + Some(&updated) + ); + assert_eq!(test_window.text_input_configurations().len(), 3); + + // Losing focus reverts the platform to the default configuration. + cx.update_window(window, |_, window, _| window.blur()) + .unwrap(); + draw(cx); + assert_eq!( + test_window.text_input_configurations().last(), + Some(&TextInputConfiguration::default()) + ); + assert_eq!(test_window.text_input_configurations().len(), 4); + } + + struct ConfigurationTestView { + focus_handle: FocusHandle, + configuration: TextInputConfiguration, + } + + impl Render for ConfigurationTestView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let focus_handle = self.focus_handle.clone(); + div().size_full().track_focus(&self.focus_handle).child( + canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, view), + cx, + ); + }, + ) + .size_full(), + ) + } + } + + impl EntityInputHandler for ConfigurationTestView { + fn text_for_range( + &mut self, + _range: std::ops::Range, + _adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) {} + + fn replace_text_in_range( + &mut self, + _range: Option>, + _text: &str, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn replace_and_mark_text_in_range( + &mut self, + _range: Option>, + _new_text: &str, + _new_selected_range: Option>, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn bounds_for_range( + &mut self, + _range_utf16: std::ops::Range, + _element_bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _point: crate::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + + fn text_input_configuration( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> TextInputConfiguration { + self.configuration.clone() + } + } } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 44b0480617..1f079485fb 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -827,6 +827,11 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn capslock(&self) -> Capslock; fn set_input_handler(&mut self, input_handler: PlatformInputHandler); fn take_input_handler(&mut self) -> Option; + /// Apply the focused text region's [`TextInputConfiguration`] to the + /// platform's text input session (e.g. attributes of the hidden editable + /// element on web). Called only when the configuration changes, because + /// reconfiguring a live input session can restart the IME connection. + fn set_text_input_configuration(&mut self, _configuration: TextInputConfiguration) {} fn prompt( &self, level: PromptLevel, @@ -1654,6 +1659,15 @@ impl PlatformInputHandler { }) .unwrap_or(false) } + + /// See [`InputHandler::text_input_configuration`]. + pub fn text_input_configuration( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> TextInputConfiguration { + self.handler.text_input_configuration(window, cx) + } } /// A struct representing a selection in a text buffer, in UTF16 characters. @@ -1823,6 +1837,84 @@ pub trait InputHandler: 'static { fn prefers_ime_for_printable_keys(&mut self, _window: &mut Window, _cx: &mut App) -> bool { false } + + /// Get this handler's preferences for platform text assistance. + /// + /// GPUI re-queries this every frame and forwards it to the platform window + /// only when it changes, so implementations must be cheap and may vary the + /// result with application state (e.g. with the cursor's position). + fn text_input_configuration( + &mut self, + _window: &mut Window, + _cx: &mut App, + ) -> TextInputConfiguration { + TextInputConfiguration::default() + } +} + +/// Platform text-assistance preferences for the focused text region. +/// +/// Returned by [`InputHandler::text_input_configuration`] and forwarded to the +/// platform whenever it changes; the platform maps the fields onto its native +/// input-session attributes (on web, DOM attributes of the hidden editable +/// element such as `autocorrect` and `enterkeyhint`). +/// +/// The default disables all text assistance and requests no particular action +/// key presentation. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TextInputConfiguration { + /// Whether the platform may automatically correct entered text. + pub autocorrect: bool, + /// How software keyboards automatically capitalize entered text. + pub autocapitalize: Autocapitalize, + /// Whether software keyboards may offer word suggestions and spellcheck. + pub suggestions: bool, + /// The action advertised on a software keyboard's confirm ("enter") key. + pub input_action: TextInputAction, +} + +/// Automatic capitalization applied by software keyboards. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Autocapitalize { + /// No automatic capitalization. + #[default] + None, + /// Capitalize the first letter of each word. + Words, + /// Capitalize the first letter of each sentence. + Sentences, + /// Capitalize every letter. + Characters, +} + +/// The action a software keyboard advertises on its confirm ("enter") key. +/// +/// This affects only how the key is presented (icon or label); pressing it is +/// still delivered as ordinary input. +/// +/// The variants are the HTML `enterkeyhint` attribute's value set +/// (), +/// which also maps onto Android's `IME_ACTION_*` constants and iOS's +/// `UIReturnKeyType`; [`TextInputAction::Unspecified`] means "emit no hint". +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TextInputAction { + /// Let the platform choose its default presentation. + #[default] + Unspecified, + /// Inserting a line break. + Enter, + /// Committing the field's value. + Done, + /// Navigating to the typed target. + Go, + /// Moving to the next field. + Next, + /// Moving to the previous field. + Previous, + /// Executing a search. + Search, + /// Sending a message. + Send, } /// The variables that can be configured when creating a new window diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 466b965e8b..223a2370e4 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -2,8 +2,8 @@ use crate::{ AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, - PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TileId, WindowAppearance, - WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, + PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration, TileId, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, }; use collections::HashMap; use gpui_util::ResultExt as _; @@ -42,6 +42,7 @@ pub(crate) struct TestWindowState { frame_scheduled: bool, frame_callback_pending: bool, input_handler: Option, + text_input_configurations: Vec, is_fullscreen: bool, appearance: WindowAppearance, external_drag_files: Vec<(PathBuf, bool)>, @@ -104,6 +105,7 @@ impl TestWindow { frame_scheduled: false, frame_callback_pending: false, input_handler: None, + text_input_configurations: Vec::new(), is_fullscreen: false, appearance: WindowAppearance::Light, external_drag_files: Vec::new(), @@ -133,6 +135,11 @@ impl TestWindow { self.0.lock().frame_scheduled } + /// Every [`TextInputConfiguration`] forwarded to this window, in order. + pub fn text_input_configurations(&self) -> Vec { + self.0.lock().text_input_configurations.clone() + } + pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); @@ -258,6 +265,10 @@ impl PlatformWindow for TestWindow { self.0.lock().input_handler.take() } + fn set_text_input_configuration(&mut self, configuration: TextInputConfiguration) { + self.0.lock().text_input_configurations.push(configuration); + } + fn prompt( &self, _level: crate::PromptLevel, diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index e677bb9331..9656cd167a 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -18,11 +18,11 @@ use crate::{ RenderImageParams, RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab, - SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, - TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, - WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, - transparent_black, + SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextInputConfiguration, + TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix, + Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, WindowBounds, + WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, point, + prelude::*, px, rems, size, transparent_black, }; use anyhow::{Context as _, Result, anyhow}; @@ -1156,6 +1156,10 @@ pub struct Window { pub(crate) element_opacity: f32, pub(crate) content_mask_stack: Vec>, pub(crate) requested_autoscroll: Option>, + /// The [`TextInputConfiguration`] most recently forwarded to the platform + /// window, so that only actual changes are forwarded (reconfiguring a live + /// input session can restart the IME connection). + last_text_input_configuration: Option, pub(crate) image_cache_stack: Vec, pub(crate) rendered_frame: Frame, pub(crate) next_frame: Frame, @@ -1848,6 +1852,7 @@ impl Window { content_mask_stack: Vec::new(), element_opacity: 1.0, requested_autoscroll: None, + last_text_input_configuration: None, rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame_callbacks, @@ -2916,6 +2921,7 @@ impl Window { { self.platform_window.set_input_handler(input_handler); } + self.apply_text_input_configuration(cx); self.layout_engine.as_mut().unwrap().clear(); self.text_system().finish_frame(); @@ -4840,6 +4846,26 @@ impl Window { } } + /// Forwards the focused input handler's [`TextInputConfiguration`] to the + /// platform window when it differs from the last forwarded value. With no + /// input handler the default configuration applies, so a field's + /// preferences don't outlive its focus. + fn apply_text_input_configuration(&mut self, cx: &mut App) { + let configuration = match self.platform_window.take_input_handler() { + Some(mut input_handler) => { + let configuration = input_handler.text_input_configuration(self, cx); + self.platform_window.set_input_handler(input_handler); + configuration + } + None => TextInputConfiguration::default(), + }; + if self.last_text_input_configuration.as_ref() != Some(&configuration) { + self.platform_window + .set_text_input_configuration(configuration.clone()); + self.last_text_input_configuration = Some(configuration); + } + } + /// Register a mouse event listener on the window for the next frame. The type of event /// is determined by the first parameter of the given listener. When the next frame is rendered /// the listener will be cleared. diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index 7543900246..d36a4b3e11 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -196,12 +196,8 @@ impl WebWindowInner { let pointer_type = event.pointer_type(); let position = pointer_position_in_element(&event); - if pointer_type != "touch" || this.pointer_targets_text_input(position) { - if pointer_type == "touch" { - this.ime_mirror.set_read_only(false); - } - this.ime_mirror.focus(); - } + this.gesture_start_visual_viewport_height + .set(this.visual_viewport_height()); // Capture the pointer so drags that leave the canvas keep // delivering pointermove/pointerup here; otherwise a release @@ -229,6 +225,17 @@ impl WebWindowInner { click_count, first_mouse: false, })); + + // Decide focus after dispatching the MouseDown so text-input + // acceptance reflects the selection produced by this tap rather + // than the previous one. This still runs within the user + // gesture, so focusing here is allowed to show the keyboard. + if pointer_type != "touch" || this.pointer_targets_text_input(position) { + if pointer_type == "touch" { + this.ime_mirror.set_read_only(false); + } + this.ime_mirror.focus(); + } }) } @@ -268,13 +275,68 @@ impl WebWindowInner { click_count, })); - if event.pointer_type() == "touch" { + // 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). Let the pointerdown decision stand instead. + let viewport_stable = + this.gesture_start_visual_viewport_height.get() == this.visual_viewport_height(); + if event.pointer_type() == "touch" && viewport_stable { this.sync_virtual_keyboard(this.pointer_targets_text_input(position)); } this.schedule_ime_mirror_sync(); }) } + /// The visual viewport's current height in layout pixels, or zero when + /// the API is unavailable. + fn visual_viewport_height(&self) -> f64 { + self.browser_window + .visual_viewport() + .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. + /// + /// 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. + 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 { + 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 + }; + self.visual_viewport_probe.set((width, max_height)); + height >= max_height * 0.85 + } + /// Cancels touch default handling separately because iOS does not consistently /// transfer pointer-event cancellation to the corresponding touch event. fn register_touch_end(self: &Rc) -> EventListenerHandle { @@ -306,12 +368,24 @@ impl WebWindowInner { fn sync_virtual_keyboard(self: &Rc, editable: bool) { let was_editable = !self.ime_mirror.read_only(); self.ime_mirror.set_read_only(!editable); - // Cycle only on an actual editability transition. Cycling on every - // tap would restart the IME connection right as the keyboard reads - // the tapped caret's context, racing its word segmentation. - if editable != was_editable { + // 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, + // racing its word segmentation. But `focus()` on an already-focused + // 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(); diff --git a/crates/gpui_web/src/ime_mirror.rs b/crates/gpui_web/src/ime_mirror.rs index 0fa4c8d1db..fa074cebaf 100644 --- a/crates/gpui_web/src/ime_mirror.rs +++ b/crates/gpui_web/src/ime_mirror.rs @@ -17,6 +17,7 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; +use gpui::{Autocapitalize, TextInputAction, TextInputConfiguration}; use wasm_bindgen::JsCast; use crate::window::WebWindowInner; @@ -100,23 +101,63 @@ impl ImeMirror { // whose font is smaller than 16px; with page zoom disabled the user // can never zoom back out, so keep the hidden IME input at 16px. style.set_property("font-size", "16px").ok(); - // The element is an IME conduit, not a form field: browser-side text - // assistance would mutate it behind the app's back. - element.set_spellcheck(false); - element.set_attribute("autocomplete", "off").ok(); - element.set_attribute("autocapitalize", "off").ok(); - element.set_attribute("autocorrect", "off").ok(); body.append_child(&element) .map_err(|e| anyhow::anyhow!("Failed to append input to body: {e:?}"))?; element.focus().ok(); - Ok(Self { + let this = Self { element, text: RefCell::new(String::new()), selection: Cell::new((0, 0)), window_hint: Cell::new(0), sync_scheduled: Cell::new(false), - }) + }; + // Until an input handler asks otherwise, the element is an IME + // conduit, not a form field: browser-side text assistance would + // mutate it behind the app's back. + this.apply_configuration(&TextInputConfiguration::default()); + Ok(this) + } + + /// Maps a [`TextInputConfiguration`] onto the element's text-assistance + /// attributes. Callers must only invoke this on actual configuration + /// changes (GPUI diffs before forwarding): mutating the focused element + /// can restart the IME's input connection. + pub(crate) fn apply_configuration(&self, configuration: &TextInputConfiguration) { + let element: &web_sys::Element = self.element.as_ref(); + self.element.set_spellcheck(configuration.suggestions); + let on_off = |enabled: bool| if enabled { "on" } else { "off" }; + element + .set_attribute("autocomplete", on_off(configuration.suggestions)) + .ok(); + element + .set_attribute("autocorrect", on_off(configuration.autocorrect)) + .ok(); + element + .set_attribute( + "autocapitalize", + match configuration.autocapitalize { + Autocapitalize::None => "off", + Autocapitalize::Words => "words", + Autocapitalize::Sentences => "sentences", + Autocapitalize::Characters => "characters", + }, + ) + .ok(); + let enter_key_hint = match configuration.input_action { + TextInputAction::Unspecified => None, + TextInputAction::Enter => Some("enter"), + TextInputAction::Done => Some("done"), + TextInputAction::Go => Some("go"), + TextInputAction::Next => Some("next"), + TextInputAction::Previous => Some("previous"), + TextInputAction::Search => Some("search"), + TextInputAction::Send => Some("send"), + }; + match enter_key_hint { + Some(hint) => element.set_attribute("enterkeyhint", hint).ok(), + None => element.remove_attribute("enterkeyhint").ok(), + }; } pub(crate) fn event_target(&self) -> &web_sys::EventTarget { @@ -127,6 +168,14 @@ impl ImeMirror { self.element.focus().ok(); } + pub(crate) fn is_focused(&self) -> bool { + let element: &web_sys::Element = self.element.as_ref(); + web_sys::window() + .and_then(|window| window.document()) + .and_then(|document| document.active_element()) + .is_some_and(|active| &active == element) + } + pub(crate) fn blur(&self) { self.element.blur().ok(); } diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index 961be26408..a724a97d9c 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -9,8 +9,8 @@ use gpui::{ AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, DispatchEventResult, GpuSpecs, Modifiers, MouseButton, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, - ResizeEdge, Scene, Size, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowControls, WindowDecorations, WindowParams, px, + ResizeEdge, Scene, Size, TextInputConfiguration, WindowAppearance, WindowBackgroundAppearance, + WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, px, }; use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig, wgpu}; use wasm_bindgen::prelude::*; @@ -63,6 +63,16 @@ pub(crate) struct WebWindowInner { /// 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 + /// the user aimed at. + pub(crate) gesture_start_visual_viewport_height: Cell, mql_handle: RefCell>, pending_physical_size: Cell>, raf_id: Cell>, @@ -185,6 +195,8 @@ 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), mql_handle: RefCell::new(None), pending_physical_size: Cell::new(None), raf_id: Cell::new(None), @@ -650,6 +662,10 @@ impl PlatformWindow for WebWindow { self.inner.state.borrow_mut().input_handler.take() } + fn set_text_input_configuration(&mut self, configuration: TextInputConfiguration) { + self.inner.ime_mirror.apply_configuration(&configuration); + } + fn prompt( &self, _level: PromptLevel,