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 ...
This commit is contained in:
1 parent
f7fcd09512
commit
a3c4abf07c
7 files changed
+502
-29
No files matched your search
+206
-1
@@ -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<Self>) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// See [`InputHandler::text_input_configuration`] for details
|
||||
fn text_input_configuration(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> TextInputConfiguration {
|
||||
TextInputConfiguration::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical implementation of [`crate::PlatformInputHandler`]. Call [`Window::handle_input`]
|
||||
@@ -244,4 +254,199 @@ impl<V: EntityInputHandler> InputHandler for ElementInputHandler<V> {
|
||||
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<Self>) -> 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<usize>,
|
||||
_adjusted_range: &mut Option<std::ops::Range<usize>>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_ignore_disabled_input: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<UTF16Selection> {
|
||||
None
|
||||
}
|
||||
|
||||
fn marked_text_range(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<std::ops::Range<usize>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
_range: Option<std::ops::Range<usize>>,
|
||||
_text: &str,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) {
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
_range: Option<std::ops::Range<usize>>,
|
||||
_new_text: &str,
|
||||
_new_selected_range: Option<std::ops::Range<usize>>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) {
|
||||
}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
_range_utf16: std::ops::Range<usize>,
|
||||
_element_bounds: Bounds<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
_point: crate::Point<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
fn text_input_configuration(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> TextInputConfiguration {
|
||||
self.configuration.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PlatformInputHandler>;
|
||||
/// 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
|
||||
/// (<https://html.spec.whatwg.org/multipage/interaction.html#input-modalities:-the-enterkeyhint-attribute>),
|
||||
/// 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
|
||||
|
||||
@@ -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<PlatformInputHandler>,
|
||||
text_input_configurations: Vec<TextInputConfiguration>,
|
||||
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<TextInputConfiguration> {
|
||||
self.0.lock().text_input_configurations.clone()
|
||||
}
|
||||
|
||||
pub fn simulate_resize(&mut self, size: Size<Pixels>) {
|
||||
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,
|
||||
|
||||
@@ -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<ContentMask<Pixels>>,
|
||||
pub(crate) requested_autoscroll: Option<Bounds<Pixels>>,
|
||||
/// 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<TextInputConfiguration>,
|
||||
pub(crate) image_cache_stack: Vec<AnyImageCache>,
|
||||
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.
|
||||
|
||||
@@ -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<Self>) -> EventListenerHandle {
|
||||
@@ -306,12 +368,24 @@ impl WebWindowInner {
|
||||
fn sync_virtual_keyboard(self: &Rc<Self>, 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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
/// the user aimed at.
|
||||
pub(crate) gesture_start_visual_viewport_height: Cell<f64>,
|
||||
mql_handle: RefCell<Option<MqlHandle>>,
|
||||
pending_physical_size: Cell<Option<(u32, u32)>>,
|
||||
raf_id: Cell<Option<i32>>,
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user