mirror of
https://github.com/servo/servo
synced 2026-04-25 17:15:48 +02:00
Per the suggestion in https://servo.zulipchat.com/#narrow/channel/263398-general/topic/PSA.3A.20avoid.20new.20usages.20of.20CanGc.20whenever.20possible/near/583995807 to mark this method as deprecated and make clear it shouldn't be used anymore, as alternatives exist. Part of #40600 Testing: It compiles Signed-off-by: Tim van der Lippe <tvanderlippe@gmail.com>
1168 lines
43 KiB
Rust
1168 lines
43 KiB
Rust
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
//! Common handling of keyboard input and state management for text input controls
|
|
|
|
use std::default::Default;
|
|
use std::ops::Range;
|
|
|
|
use bitflags::bitflags;
|
|
use keyboard_types::{Key, KeyState, Modifiers, NamedKey, ShortcutMatcher};
|
|
use script_bindings::codegen::GenericBindings::MouseEventBinding::MouseEventMethods;
|
|
use script_bindings::codegen::GenericBindings::UIEventBinding::UIEventMethods;
|
|
use script_bindings::match_domstring_ascii;
|
|
use script_bindings::trace::CustomTraceable;
|
|
use servo_base::text::{Utf8CodeUnitLength, Utf16CodeUnitLength};
|
|
use servo_base::{Rope, RopeIndex, RopeMovement, RopeSlice};
|
|
|
|
use crate::clipboard_provider::ClipboardProvider;
|
|
use crate::dom::bindings::codegen::Bindings::EventBinding::Event_Binding::EventMethods;
|
|
use crate::dom::bindings::inheritance::Castable;
|
|
use crate::dom::bindings::refcounted::Trusted;
|
|
use crate::dom::bindings::reflector::DomGlobal;
|
|
use crate::dom::bindings::str::DOMString;
|
|
use crate::dom::compositionevent::CompositionEvent;
|
|
use crate::dom::event::Event;
|
|
use crate::dom::eventtarget::EventTarget;
|
|
use crate::dom::inputevent::InputEvent;
|
|
use crate::dom::keyboardevent::KeyboardEvent;
|
|
use crate::dom::mouseevent::MouseEvent;
|
|
use crate::dom::node::{Node, NodeTraits};
|
|
use crate::dom::types::{ClipboardEvent, UIEvent};
|
|
use crate::drag_data_store::Kind;
|
|
use crate::script_runtime::CanGc;
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
pub enum Selection {
|
|
Selected,
|
|
NotSelected,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, JSTraceable, MallocSizeOf, PartialEq)]
|
|
pub enum SelectionDirection {
|
|
Forward,
|
|
Backward,
|
|
None,
|
|
}
|
|
|
|
impl From<DOMString> for SelectionDirection {
|
|
fn from(direction: DOMString) -> SelectionDirection {
|
|
match_domstring_ascii!(direction,
|
|
"forward" => SelectionDirection::Forward,
|
|
"backward" => SelectionDirection::Backward,
|
|
_ => SelectionDirection::None,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl From<SelectionDirection> for DOMString {
|
|
fn from(direction: SelectionDirection) -> DOMString {
|
|
match direction {
|
|
SelectionDirection::Forward => DOMString::from("forward"),
|
|
SelectionDirection::Backward => DOMString::from("backward"),
|
|
SelectionDirection::None => DOMString::from("none"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, JSTraceable, MallocSizeOf)]
|
|
pub enum Lines {
|
|
Single,
|
|
Multiple,
|
|
}
|
|
|
|
impl Lines {
|
|
fn normalize(&self, contents: impl Into<String>) -> String {
|
|
let contents = contents.into().replace("\r\n", "\n");
|
|
match self {
|
|
Self::Multiple => {
|
|
// https://html.spec.whatwg.org/multipage/#textarea-line-break-normalisation-transformation
|
|
contents.replace("\r", "\n")
|
|
},
|
|
// https://infra.spec.whatwg.org/#strip-newlines
|
|
//
|
|
// Browsers generally seem to convert newlines to spaces, so we do the same.
|
|
Lines::Single => contents.replace(['\r', '\n'], " "),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
pub(crate) struct SelectionState {
|
|
start: RopeIndex,
|
|
end: RopeIndex,
|
|
direction: SelectionDirection,
|
|
}
|
|
|
|
/// Encapsulated state for handling keyboard input in a single or multiline text input control.
|
|
#[derive(JSTraceable, MallocSizeOf)]
|
|
pub struct TextInput<T: ClipboardProvider> {
|
|
#[no_trace]
|
|
rope: Rope,
|
|
|
|
/// The type of [`TextInput`] this is. When in multi-line mode, the [`TextInput`] will
|
|
/// automatically split all inserted text into lines and incorporate them into
|
|
/// the [`Self::rope`]. When in single line mode, the inserted text will be stripped of
|
|
/// newlines.
|
|
mode: Lines,
|
|
|
|
/// Current cursor input point
|
|
#[no_trace]
|
|
edit_point: RopeIndex,
|
|
|
|
/// The current selection goes from the selection_origin until the edit_point. Note that the
|
|
/// selection_origin may be after the edit_point, in the case of a backward selection.
|
|
#[no_trace]
|
|
selection_origin: Option<RopeIndex>,
|
|
selection_direction: SelectionDirection,
|
|
|
|
#[ignore_malloc_size_of = "Can't easily measure this generic type"]
|
|
clipboard_provider: T,
|
|
|
|
/// The maximum number of UTF-16 code units this text input is allowed to hold.
|
|
///
|
|
/// <https://html.spec.whatwg.org/multipage/#attr-fe-maxlength>
|
|
max_length: Option<Utf16CodeUnitLength>,
|
|
min_length: Option<Utf16CodeUnitLength>,
|
|
|
|
/// Was last change made by set_content?
|
|
was_last_change_by_set_content: bool,
|
|
|
|
/// Whether or not we are currently dragging in this [`TextInput`].
|
|
currently_dragging: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
pub enum IsComposing {
|
|
Composing,
|
|
NotComposing,
|
|
}
|
|
|
|
impl From<IsComposing> for bool {
|
|
fn from(is_composing: IsComposing) -> Self {
|
|
match is_composing {
|
|
IsComposing::Composing => true,
|
|
IsComposing::NotComposing => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <https://www.w3.org/TR/input-events-2/#interface-InputEvent-Attributes>
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
pub enum InputType {
|
|
InsertText,
|
|
InsertLineBreak,
|
|
InsertFromPaste,
|
|
InsertCompositionText,
|
|
DeleteByCut,
|
|
DeleteContentBackward,
|
|
DeleteContentForward,
|
|
Nothing,
|
|
}
|
|
|
|
impl InputType {
|
|
fn as_str(&self) -> &str {
|
|
match *self {
|
|
InputType::InsertText => "insertText",
|
|
InputType::InsertLineBreak => "insertLineBreak",
|
|
InputType::InsertFromPaste => "insertFromPaste",
|
|
InputType::InsertCompositionText => "insertCompositionText",
|
|
InputType::DeleteByCut => "deleteByCut",
|
|
InputType::DeleteContentBackward => "deleteContentBackward",
|
|
InputType::DeleteContentForward => "deleteContentForward",
|
|
InputType::Nothing => "",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resulting action to be taken by the owner of a text input that is handling an event.
|
|
pub enum KeyReaction {
|
|
TriggerDefaultAction,
|
|
DispatchInput(Option<String>, IsComposing, InputType),
|
|
RedrawSelection,
|
|
Nothing,
|
|
}
|
|
|
|
bitflags! {
|
|
/// Resulting action to be taken by the owner of a text input that is handling a clipboard
|
|
/// event.
|
|
#[derive(Clone, Copy)]
|
|
pub struct ClipboardEventFlags: u8 {
|
|
const QueueInputEvent = 1 << 0;
|
|
const FireClipboardChangedEvent = 1 << 1;
|
|
}
|
|
}
|
|
|
|
pub struct ClipboardEventReaction {
|
|
pub flags: ClipboardEventFlags,
|
|
pub text: Option<String>,
|
|
pub input_type: InputType,
|
|
}
|
|
|
|
impl ClipboardEventReaction {
|
|
fn new(flags: ClipboardEventFlags) -> Self {
|
|
Self {
|
|
flags,
|
|
text: None,
|
|
input_type: InputType::Nothing,
|
|
}
|
|
}
|
|
|
|
fn with_text(mut self, text: String) -> Self {
|
|
self.text = Some(text);
|
|
self
|
|
}
|
|
|
|
fn with_input_type(mut self, input_type: InputType) -> Self {
|
|
self.input_type = input_type;
|
|
self
|
|
}
|
|
|
|
fn empty() -> Self {
|
|
Self::new(ClipboardEventFlags::empty())
|
|
}
|
|
}
|
|
|
|
/// The direction in which to delete a character.
|
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
|
pub enum Direction {
|
|
Forward,
|
|
Backward,
|
|
}
|
|
|
|
// Some shortcuts use Cmd on Mac and Control on other systems.
|
|
#[cfg(target_os = "macos")]
|
|
pub(crate) const CMD_OR_CONTROL: Modifiers = Modifiers::META;
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub(crate) const CMD_OR_CONTROL: Modifiers = Modifiers::CONTROL;
|
|
|
|
/// The length in bytes of the first n code units in a string when encoded in UTF-16.
|
|
///
|
|
/// If the string is fewer than n code units, returns the length of the whole string.
|
|
fn len_of_first_n_code_units(text: &DOMString, n: Utf16CodeUnitLength) -> Utf8CodeUnitLength {
|
|
let mut utf8_len = Utf8CodeUnitLength::zero();
|
|
let mut utf16_len = Utf16CodeUnitLength::zero();
|
|
for c in text.str().chars() {
|
|
utf16_len += Utf16CodeUnitLength(c.len_utf16());
|
|
if utf16_len > n {
|
|
break;
|
|
}
|
|
utf8_len += Utf8CodeUnitLength(c.len_utf8());
|
|
}
|
|
utf8_len
|
|
}
|
|
|
|
impl<T: ClipboardProvider> TextInput<T> {
|
|
/// Instantiate a new text input control
|
|
pub fn new(lines: Lines, initial: DOMString, clipboard_provider: T) -> TextInput<T> {
|
|
Self {
|
|
rope: Rope::new(initial),
|
|
mode: lines,
|
|
edit_point: Default::default(),
|
|
selection_origin: None,
|
|
clipboard_provider,
|
|
max_length: Default::default(),
|
|
min_length: Default::default(),
|
|
selection_direction: SelectionDirection::None,
|
|
was_last_change_by_set_content: true,
|
|
currently_dragging: Default::default(),
|
|
}
|
|
}
|
|
|
|
pub fn edit_point(&self) -> RopeIndex {
|
|
self.edit_point
|
|
}
|
|
|
|
pub fn selection_origin(&self) -> Option<RopeIndex> {
|
|
self.selection_origin
|
|
}
|
|
|
|
/// The selection origin, or the edit point if there is no selection. Note that the selection
|
|
/// origin may be after the edit point, in the case of a backward selection.
|
|
pub fn selection_origin_or_edit_point(&self) -> RopeIndex {
|
|
self.selection_origin.unwrap_or(self.edit_point)
|
|
}
|
|
|
|
pub fn selection_direction(&self) -> SelectionDirection {
|
|
self.selection_direction
|
|
}
|
|
|
|
pub fn set_max_length(&mut self, length: Option<Utf16CodeUnitLength>) {
|
|
self.max_length = length;
|
|
}
|
|
|
|
pub fn set_min_length(&mut self, length: Option<Utf16CodeUnitLength>) {
|
|
self.min_length = length;
|
|
}
|
|
|
|
/// Was last edit made by set_content?
|
|
pub(crate) fn was_last_change_by_set_content(&self) -> bool {
|
|
self.was_last_change_by_set_content
|
|
}
|
|
|
|
/// If there is an uncollapsed selection, delete it, otherwise do nothing. Returns
|
|
/// true if any text was deleted.
|
|
fn delete_selection(&mut self) -> bool {
|
|
if self.selection_start() == self.selection_end() {
|
|
return false;
|
|
}
|
|
self.replace_selection(&DOMString::new());
|
|
true
|
|
}
|
|
|
|
/// If there is an uncollapsed selection, delete it. Otherwise delete the given [`unit`]
|
|
/// worth of text in [`direction`] Remove a character at the current editing point
|
|
///
|
|
/// Returns true if any text was deleted.
|
|
pub fn delete_unit_or_selection(&mut self, unit: RopeMovement, direction: Direction) -> bool {
|
|
if !self.has_uncollapsed_selection() {
|
|
let amount = match direction {
|
|
Direction::Forward => 1,
|
|
Direction::Backward => -1,
|
|
};
|
|
self.modify_selection(amount, unit);
|
|
}
|
|
self.delete_selection()
|
|
}
|
|
|
|
/// Insert a string at the current editing point or replace the selection if
|
|
/// one exists.
|
|
pub fn insert<S: Into<String>>(&mut self, string: S) {
|
|
if self.selection_origin.is_none() {
|
|
self.selection_origin = Some(self.edit_point);
|
|
}
|
|
self.replace_selection(&DOMString::from(string.into()));
|
|
}
|
|
|
|
/// The start of the selection (or the edit point, if there is no selection). Always less than
|
|
/// or equal to selection_end(), regardless of the selection direction.
|
|
pub fn selection_start(&self) -> RopeIndex {
|
|
match self.selection_direction {
|
|
SelectionDirection::None | SelectionDirection::Forward => {
|
|
self.selection_origin_or_edit_point()
|
|
},
|
|
SelectionDirection::Backward => self.edit_point,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn selection_start_utf16(&self) -> Utf16CodeUnitLength {
|
|
self.rope.index_to_utf16_offset(self.selection_start())
|
|
}
|
|
|
|
/// The byte offset of the selection_start()
|
|
fn selection_start_offset(&self) -> Utf8CodeUnitLength {
|
|
self.rope.index_to_utf8_offset(self.selection_start())
|
|
}
|
|
|
|
/// The end of the selection (or the edit point, if there is no selection). Always greater
|
|
/// than or equal to selection_start(), regardless of the selection direction.
|
|
pub fn selection_end(&self) -> RopeIndex {
|
|
match self.selection_direction {
|
|
SelectionDirection::None | SelectionDirection::Forward => self.edit_point,
|
|
SelectionDirection::Backward => self.selection_origin_or_edit_point(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn selection_end_utf16(&self) -> Utf16CodeUnitLength {
|
|
self.rope.index_to_utf16_offset(self.selection_end())
|
|
}
|
|
|
|
/// The byte offset of the selection_end()
|
|
pub fn selection_end_offset(&self) -> Utf8CodeUnitLength {
|
|
self.rope.index_to_utf8_offset(self.selection_end())
|
|
}
|
|
|
|
/// Whether or not there is an active uncollapsed selection. This means that the
|
|
/// selection origin is set and it differs from the edit point.
|
|
#[inline]
|
|
pub(crate) fn has_uncollapsed_selection(&self) -> bool {
|
|
self.selection_origin
|
|
.is_some_and(|selection_origin| selection_origin != self.edit_point)
|
|
}
|
|
|
|
/// Return the selection range as byte offsets from the start of the content.
|
|
///
|
|
/// If there is no selection, returns an empty range at the edit point.
|
|
pub(crate) fn sorted_selection_offsets_range(&self) -> Range<Utf8CodeUnitLength> {
|
|
self.selection_start_offset()..self.selection_end_offset()
|
|
}
|
|
|
|
/// Return the selection range as character offsets from the start of the content.
|
|
///
|
|
/// If there is no selection, returns an empty range at the edit point.
|
|
pub(crate) fn sorted_selection_character_offsets_range(&self) -> Range<usize> {
|
|
self.rope.index_to_character_offset(self.selection_start())..
|
|
self.rope.index_to_character_offset(self.selection_end())
|
|
}
|
|
|
|
/// The state of the current selection. Can be used to compare whether selection state has changed.
|
|
pub(crate) fn selection_state(&self) -> SelectionState {
|
|
SelectionState {
|
|
start: self.selection_start(),
|
|
end: self.selection_end(),
|
|
direction: self.selection_direction,
|
|
}
|
|
}
|
|
|
|
// Check that the selection is valid.
|
|
fn assert_ok_selection(&self) {
|
|
debug!(
|
|
"edit_point: {:?}, selection_origin: {:?}, direction: {:?}",
|
|
self.edit_point, self.selection_origin, self.selection_direction
|
|
);
|
|
|
|
debug_assert_eq!(self.edit_point, self.rope.normalize_index(self.edit_point));
|
|
if let Some(selection_origin) = self.selection_origin {
|
|
debug_assert_eq!(
|
|
selection_origin,
|
|
self.rope.normalize_index(selection_origin)
|
|
);
|
|
match self.selection_direction {
|
|
SelectionDirection::None | SelectionDirection::Forward => {
|
|
debug_assert!(selection_origin <= self.edit_point)
|
|
},
|
|
SelectionDirection::Backward => debug_assert!(self.edit_point <= selection_origin),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn selection_slice(&self) -> RopeSlice<'_> {
|
|
self.rope
|
|
.slice(Some(self.selection_start()), Some(self.selection_end()))
|
|
}
|
|
|
|
pub(crate) fn get_selection_text(&self) -> Option<String> {
|
|
let text: String = self.selection_slice().into();
|
|
if text.is_empty() {
|
|
return None;
|
|
}
|
|
Some(text)
|
|
}
|
|
|
|
/// The length of the selected text in UTF-16 code units.
|
|
fn selection_utf16_len(&self) -> Utf16CodeUnitLength {
|
|
Utf16CodeUnitLength(
|
|
self.selection_slice()
|
|
.chars()
|
|
.map(char::len_utf16)
|
|
.sum::<usize>(),
|
|
)
|
|
}
|
|
|
|
/// Replace the current selection with the given [`DOMString`]. If the [`Rope`] is in
|
|
/// single line mode this *will* strip newlines, as opposed to [`Self::set_content`],
|
|
/// which does not.
|
|
pub fn replace_selection(&mut self, insert: &DOMString) {
|
|
let string_to_insert = if let Some(max_length) = self.max_length {
|
|
let utf16_length_without_selection =
|
|
self.len_utf16().saturating_sub(self.selection_utf16_len());
|
|
let utf16_length_that_can_be_inserted =
|
|
max_length.saturating_sub(utf16_length_without_selection);
|
|
let Utf8CodeUnitLength(last_char_index) =
|
|
len_of_first_n_code_units(insert, utf16_length_that_can_be_inserted);
|
|
&insert.str()[..last_char_index]
|
|
} else {
|
|
&insert.str()
|
|
};
|
|
let string_to_insert = self.mode.normalize(string_to_insert);
|
|
|
|
let start = self.selection_start();
|
|
let end = self.selection_end();
|
|
let end_index_of_insertion = self.rope.replace_range(start..end, string_to_insert);
|
|
|
|
self.was_last_change_by_set_content = false;
|
|
self.clear_selection();
|
|
self.edit_point = end_index_of_insertion;
|
|
}
|
|
|
|
pub fn modify_edit_point(&mut self, amount: isize, movement: RopeMovement) {
|
|
if amount == 0 {
|
|
return;
|
|
}
|
|
|
|
// When moving by lines or if we do not have a selection, we do actually move
|
|
// the edit point from its position.
|
|
if matches!(movement, RopeMovement::Line) || !self.has_uncollapsed_selection() {
|
|
self.clear_selection();
|
|
self.edit_point = self.rope.move_by(self.edit_point, movement, amount);
|
|
return;
|
|
}
|
|
|
|
// If there's a selection and we are moving by words or characters, we just collapse
|
|
// the selection in the direction of the motion.
|
|
let new_edit_point = if amount > 0 {
|
|
self.selection_end()
|
|
} else {
|
|
self.selection_start()
|
|
};
|
|
self.clear_selection();
|
|
self.edit_point = new_edit_point;
|
|
}
|
|
|
|
pub fn modify_selection(&mut self, amount: isize, movement: RopeMovement) {
|
|
let old_edit_point = self.edit_point;
|
|
self.edit_point = self.rope.move_by(old_edit_point, movement, amount);
|
|
|
|
if self.selection_origin.is_none() {
|
|
self.selection_origin = Some(old_edit_point);
|
|
}
|
|
self.update_selection_direction();
|
|
}
|
|
|
|
pub fn modify_selection_or_edit_point(
|
|
&mut self,
|
|
amount: isize,
|
|
movement: RopeMovement,
|
|
select: Selection,
|
|
) {
|
|
match select {
|
|
Selection::Selected => self.modify_selection(amount, movement),
|
|
Selection::NotSelected => self.modify_edit_point(amount, movement),
|
|
}
|
|
self.assert_ok_selection();
|
|
}
|
|
|
|
/// Update the field selection_direction.
|
|
///
|
|
/// When the edit_point (or focus) is before the selection_origin (or anchor)
|
|
/// you have a backward selection. Otherwise you have a forward selection.
|
|
fn update_selection_direction(&mut self) {
|
|
debug!(
|
|
"edit_point: {:?}, selection_origin: {:?}",
|
|
self.edit_point, self.selection_origin
|
|
);
|
|
self.selection_direction = if Some(self.edit_point) < self.selection_origin {
|
|
SelectionDirection::Backward
|
|
} else {
|
|
SelectionDirection::Forward
|
|
}
|
|
}
|
|
|
|
/// Deal with a newline input.
|
|
pub fn handle_return(&mut self) -> KeyReaction {
|
|
match self.mode {
|
|
Lines::Multiple => {
|
|
self.insert('\n');
|
|
KeyReaction::DispatchInput(
|
|
None,
|
|
IsComposing::NotComposing,
|
|
InputType::InsertLineBreak,
|
|
)
|
|
},
|
|
Lines::Single => KeyReaction::TriggerDefaultAction,
|
|
}
|
|
}
|
|
|
|
/// Select all text in the input control.
|
|
pub fn select_all(&mut self) {
|
|
self.selection_origin = Some(RopeIndex::default());
|
|
self.edit_point = self.rope.last_index();
|
|
self.selection_direction = SelectionDirection::Forward;
|
|
self.assert_ok_selection();
|
|
}
|
|
|
|
/// Remove the current selection.
|
|
pub fn clear_selection(&mut self) {
|
|
self.selection_origin = None;
|
|
self.selection_direction = SelectionDirection::None;
|
|
}
|
|
|
|
/// Remove the current selection and set the edit point to the end of the content.
|
|
pub(crate) fn clear_selection_to_end(&mut self) {
|
|
self.clear_selection();
|
|
self.edit_point = self.rope.last_index();
|
|
}
|
|
|
|
pub(crate) fn clear_selection_to_start(&mut self) {
|
|
self.clear_selection();
|
|
self.edit_point = Default::default();
|
|
}
|
|
|
|
/// Process a given `KeyboardEvent` and return an action for the caller to execute.
|
|
pub(crate) fn handle_keydown(&mut self, event: &KeyboardEvent) -> KeyReaction {
|
|
let key = event.key();
|
|
let mods = event.modifiers();
|
|
self.handle_keydown_aux(key, mods, cfg!(target_os = "macos"))
|
|
}
|
|
|
|
// This function exists for easy unit testing.
|
|
// To test Mac OS shortcuts on other systems a flag is passed.
|
|
pub fn handle_keydown_aux(
|
|
&mut self,
|
|
key: Key,
|
|
mut mods: Modifiers,
|
|
macos: bool,
|
|
) -> KeyReaction {
|
|
let maybe_select = if mods.contains(Modifiers::SHIFT) {
|
|
Selection::Selected
|
|
} else {
|
|
Selection::NotSelected
|
|
};
|
|
|
|
let alt_or_control = if macos {
|
|
Modifiers::ALT
|
|
} else {
|
|
Modifiers::CONTROL
|
|
};
|
|
|
|
mods.remove(Modifiers::SHIFT);
|
|
ShortcutMatcher::new(KeyState::Down, key.clone(), mods)
|
|
.shortcut(Modifiers::CONTROL | Modifiers::ALT, 'B', || {
|
|
self.modify_selection_or_edit_point(-1, RopeMovement::Word, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(Modifiers::CONTROL | Modifiers::ALT, 'F', || {
|
|
self.modify_selection_or_edit_point(1, RopeMovement::Word, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(Modifiers::CONTROL | Modifiers::ALT, 'A', || {
|
|
self.modify_selection_or_edit_point(-1, RopeMovement::LineStartOrEnd, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(Modifiers::CONTROL | Modifiers::ALT, 'E', || {
|
|
self.modify_selection_or_edit_point(1, RopeMovement::LineStartOrEnd, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.optional_shortcut(macos, Modifiers::CONTROL, 'A', || {
|
|
self.modify_selection_or_edit_point(-1, RopeMovement::LineStartOrEnd, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.optional_shortcut(macos, Modifiers::CONTROL, 'E', || {
|
|
self.modify_selection_or_edit_point(1, RopeMovement::LineStartOrEnd, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(CMD_OR_CONTROL, 'A', || {
|
|
self.select_all();
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(CMD_OR_CONTROL, 'X', || {
|
|
if let Some(text) = self.get_selection_text() {
|
|
self.clipboard_provider.set_text(text);
|
|
self.delete_selection();
|
|
}
|
|
KeyReaction::DispatchInput(None, IsComposing::NotComposing, InputType::DeleteByCut)
|
|
})
|
|
.shortcut(CMD_OR_CONTROL, 'C', || {
|
|
// TODO(stevennovaryo): we should not provide text to clipboard for type=password
|
|
if let Some(text) = self.get_selection_text() {
|
|
self.clipboard_provider.set_text(text);
|
|
}
|
|
KeyReaction::DispatchInput(None, IsComposing::NotComposing, InputType::Nothing)
|
|
})
|
|
.shortcut(CMD_OR_CONTROL, 'V', || {
|
|
if let Ok(text_content) = self.clipboard_provider.get_text() {
|
|
self.insert(&text_content);
|
|
KeyReaction::DispatchInput(
|
|
Some(text_content),
|
|
IsComposing::NotComposing,
|
|
InputType::InsertFromPaste,
|
|
)
|
|
} else {
|
|
KeyReaction::DispatchInput(
|
|
Some("".to_string()),
|
|
IsComposing::NotComposing,
|
|
InputType::InsertFromPaste,
|
|
)
|
|
}
|
|
})
|
|
.shortcut(Modifiers::empty(), Key::Named(NamedKey::Delete), || {
|
|
if self.delete_unit_or_selection(RopeMovement::Grapheme, Direction::Forward) {
|
|
KeyReaction::DispatchInput(
|
|
None,
|
|
IsComposing::NotComposing,
|
|
InputType::DeleteContentForward,
|
|
)
|
|
} else {
|
|
KeyReaction::Nothing
|
|
}
|
|
})
|
|
.shortcut(Modifiers::empty(), Key::Named(NamedKey::Backspace), || {
|
|
if self.delete_unit_or_selection(RopeMovement::Grapheme, Direction::Backward) {
|
|
KeyReaction::DispatchInput(
|
|
None,
|
|
IsComposing::NotComposing,
|
|
InputType::DeleteContentBackward,
|
|
)
|
|
} else {
|
|
KeyReaction::Nothing
|
|
}
|
|
})
|
|
.shortcut(alt_or_control, Key::Named(NamedKey::Backspace), || {
|
|
if self.delete_unit_or_selection(RopeMovement::Word, Direction::Backward) {
|
|
KeyReaction::DispatchInput(
|
|
None,
|
|
IsComposing::NotComposing,
|
|
InputType::DeleteContentBackward,
|
|
)
|
|
} else {
|
|
KeyReaction::Nothing
|
|
}
|
|
})
|
|
.optional_shortcut(
|
|
macos,
|
|
Modifiers::META,
|
|
Key::Named(NamedKey::ArrowLeft),
|
|
|| {
|
|
self.modify_selection_or_edit_point(
|
|
-1,
|
|
RopeMovement::LineStartOrEnd,
|
|
maybe_select,
|
|
);
|
|
KeyReaction::RedrawSelection
|
|
},
|
|
)
|
|
.optional_shortcut(
|
|
macos,
|
|
Modifiers::META,
|
|
Key::Named(NamedKey::ArrowRight),
|
|
|| {
|
|
self.modify_selection_or_edit_point(
|
|
1,
|
|
RopeMovement::LineStartOrEnd,
|
|
maybe_select,
|
|
);
|
|
KeyReaction::RedrawSelection
|
|
},
|
|
)
|
|
.optional_shortcut(
|
|
macos,
|
|
Modifiers::META,
|
|
Key::Named(NamedKey::ArrowUp),
|
|
|| {
|
|
self.modify_selection_or_edit_point(
|
|
-1,
|
|
RopeMovement::RopeStartOrEnd,
|
|
maybe_select,
|
|
);
|
|
KeyReaction::RedrawSelection
|
|
},
|
|
)
|
|
.optional_shortcut(
|
|
macos,
|
|
Modifiers::META,
|
|
Key::Named(NamedKey::ArrowDown),
|
|
|| {
|
|
self.modify_selection_or_edit_point(
|
|
1,
|
|
RopeMovement::RopeStartOrEnd,
|
|
maybe_select,
|
|
);
|
|
KeyReaction::RedrawSelection
|
|
},
|
|
)
|
|
.shortcut(alt_or_control, Key::Named(NamedKey::ArrowLeft), || {
|
|
self.modify_selection_or_edit_point(-1, RopeMovement::Word, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(alt_or_control, Key::Named(NamedKey::ArrowRight), || {
|
|
self.modify_selection_or_edit_point(1, RopeMovement::Word, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(Modifiers::empty(), Key::Named(NamedKey::ArrowLeft), || {
|
|
self.modify_selection_or_edit_point(-1, RopeMovement::Grapheme, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(Modifiers::empty(), Key::Named(NamedKey::ArrowRight), || {
|
|
self.modify_selection_or_edit_point(1, RopeMovement::Grapheme, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(Modifiers::empty(), Key::Named(NamedKey::ArrowUp), || {
|
|
self.modify_selection_or_edit_point(-1, RopeMovement::Line, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(Modifiers::empty(), Key::Named(NamedKey::ArrowDown), || {
|
|
self.modify_selection_or_edit_point(1, RopeMovement::Line, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(Modifiers::empty(), Key::Named(NamedKey::Enter), || {
|
|
self.handle_return()
|
|
})
|
|
.optional_shortcut(
|
|
macos,
|
|
Modifiers::empty(),
|
|
Key::Named(NamedKey::Home),
|
|
|| {
|
|
self.modify_selection_or_edit_point(
|
|
-1,
|
|
RopeMovement::RopeStartOrEnd,
|
|
maybe_select,
|
|
);
|
|
KeyReaction::RedrawSelection
|
|
},
|
|
)
|
|
.optional_shortcut(macos, Modifiers::empty(), Key::Named(NamedKey::End), || {
|
|
self.modify_selection_or_edit_point(1, RopeMovement::RopeStartOrEnd, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(Modifiers::empty(), Key::Named(NamedKey::PageUp), || {
|
|
self.modify_selection_or_edit_point(-28, RopeMovement::Line, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.shortcut(Modifiers::empty(), Key::Named(NamedKey::PageDown), || {
|
|
self.modify_selection_or_edit_point(28, RopeMovement::Line, maybe_select);
|
|
KeyReaction::RedrawSelection
|
|
})
|
|
.otherwise(|| {
|
|
if let Key::Character(ref character) = key {
|
|
self.insert(character);
|
|
return KeyReaction::DispatchInput(
|
|
Some(character.to_string()),
|
|
IsComposing::NotComposing,
|
|
InputType::InsertText,
|
|
);
|
|
}
|
|
if matches!(key, Key::Named(NamedKey::Process)) {
|
|
return KeyReaction::DispatchInput(
|
|
None,
|
|
IsComposing::Composing,
|
|
InputType::Nothing,
|
|
);
|
|
}
|
|
KeyReaction::Nothing
|
|
})
|
|
.unwrap()
|
|
}
|
|
|
|
pub(crate) fn handle_compositionend(&mut self, event: &CompositionEvent) -> KeyReaction {
|
|
let insertion = event.data().str();
|
|
if insertion.is_empty() {
|
|
self.clear_selection();
|
|
return KeyReaction::RedrawSelection;
|
|
}
|
|
|
|
self.insert(insertion.to_string());
|
|
KeyReaction::DispatchInput(
|
|
Some(insertion.to_string()),
|
|
IsComposing::NotComposing,
|
|
InputType::InsertCompositionText,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn handle_compositionupdate(&mut self, event: &CompositionEvent) -> KeyReaction {
|
|
let insertion = event.data().str();
|
|
if insertion.is_empty() {
|
|
return KeyReaction::Nothing;
|
|
}
|
|
|
|
let start = self.selection_start_offset();
|
|
let insertion = insertion.to_string();
|
|
self.insert(insertion.clone());
|
|
self.set_selection_range_utf8(
|
|
start,
|
|
start + event.data().len_utf8(),
|
|
SelectionDirection::Forward,
|
|
);
|
|
KeyReaction::DispatchInput(
|
|
Some(insertion),
|
|
IsComposing::Composing,
|
|
InputType::InsertCompositionText,
|
|
)
|
|
}
|
|
|
|
fn edit_point_for_mouse_event(&self, node: &Node, event: &MouseEvent) -> RopeIndex {
|
|
node.owner_window()
|
|
.text_index_query_on_node_for_event(node, event)
|
|
.map(|grapheme_index| {
|
|
self.rope.move_by(
|
|
Default::default(),
|
|
RopeMovement::Character,
|
|
grapheme_index as isize,
|
|
)
|
|
})
|
|
.unwrap_or_else(|| self.rope.last_index())
|
|
}
|
|
|
|
/// Handle a mouse even that has happened in this [`TextInput`]. Returns `true` if the selection
|
|
/// in the input may have changed and `false` otherwise.
|
|
pub(crate) fn handle_mouse_event(&mut self, node: &Node, mouse_event: &MouseEvent) -> bool {
|
|
// Cancel any ongoing drags if we see a mouseup of any kind or notice
|
|
// that a button other than the primary button is pressed.
|
|
let event_type = mouse_event.upcast::<Event>().type_();
|
|
if event_type == atom!("mouseup") || mouse_event.Buttons() & 1 != 1 {
|
|
self.currently_dragging = false;
|
|
}
|
|
|
|
if event_type == atom!("mousedown") {
|
|
return self.handle_mousedown(node, mouse_event);
|
|
}
|
|
|
|
if event_type == atom!("mousemove") && self.currently_dragging {
|
|
self.edit_point = self.edit_point_for_mouse_event(node, mouse_event);
|
|
self.update_selection_direction();
|
|
return true;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// Handle a "mousedown" event that happened on this [`TextInput`], belonging to the
|
|
/// given [`Node`].
|
|
///
|
|
/// Returns `true` if the [`TextInput`] changed at all or `false` otherwise.
|
|
fn handle_mousedown(&mut self, node: &Node, mouse_event: &MouseEvent) -> bool {
|
|
assert_eq!(mouse_event.upcast::<Event>().type_(), atom!("mousedown"));
|
|
|
|
// Only update the cursor in text fields when the primary buton is pressed.
|
|
//
|
|
// From <https://w3c.github.io/uievents/#dom-mouseevent-button>:
|
|
// > 0 MUST indicate the primary button of the device (in general, the left button
|
|
// > or the only button on single-button devices, used to activate a user interface
|
|
// > control or select text) or the un-initialized value.
|
|
if mouse_event.Button() != 0 {
|
|
return false;
|
|
}
|
|
|
|
self.currently_dragging = true;
|
|
match mouse_event.upcast::<UIEvent>().Detail() {
|
|
3 => {
|
|
let word_boundaries = self.rope.line_boundaries(self.edit_point);
|
|
self.edit_point = word_boundaries.end;
|
|
self.selection_origin = Some(word_boundaries.start);
|
|
self.update_selection_direction();
|
|
true
|
|
},
|
|
2 => {
|
|
let word_boundaries = self.rope.relevant_word_boundaries(self.edit_point);
|
|
self.edit_point = word_boundaries.end;
|
|
self.selection_origin = Some(word_boundaries.start);
|
|
self.update_selection_direction();
|
|
true
|
|
},
|
|
1 => {
|
|
self.clear_selection();
|
|
self.edit_point = self.edit_point_for_mouse_event(node, mouse_event);
|
|
self.selection_origin = Some(self.edit_point);
|
|
self.update_selection_direction();
|
|
true
|
|
},
|
|
_ => {
|
|
// We currently don't do anything for higher click counts, but some platforms do.
|
|
// We should re-examine this when implementing support for platform-specific editing
|
|
// behaviors.
|
|
false
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Whether the content is empty.
|
|
pub(crate) fn is_empty(&self) -> bool {
|
|
self.rope.is_empty()
|
|
}
|
|
|
|
/// The total number of code units required to encode the content in utf16.
|
|
pub(crate) fn len_utf16(&self) -> Utf16CodeUnitLength {
|
|
self.rope.len_utf16()
|
|
}
|
|
|
|
/// Get the current contents of the text input. Multiple lines are joined by \n.
|
|
pub fn get_content(&self) -> DOMString {
|
|
self.rope.contents().into()
|
|
}
|
|
|
|
/// Set the current contents of the text input. If this is control supports multiple lines,
|
|
/// any \n encountered will be stripped and force a new logical line.
|
|
///
|
|
/// Note that when the [`Rope`] is in single line mode, this will **not** strip newlines.
|
|
/// Newline stripping only happens for incremental updates to the [`Rope`] as `<input>`
|
|
/// elements currently need to store unsanitized values while being created.
|
|
pub fn set_content(&mut self, content: DOMString) {
|
|
self.rope = Rope::new(
|
|
content
|
|
.str()
|
|
.to_string()
|
|
.replace("\r\n", "\n")
|
|
.replace("\r", "\n"),
|
|
);
|
|
self.was_last_change_by_set_content = true;
|
|
|
|
self.edit_point = self.rope.normalize_index(self.edit_point());
|
|
self.selection_origin = self
|
|
.selection_origin
|
|
.map(|selection_origin| self.rope.normalize_index(selection_origin));
|
|
}
|
|
|
|
pub fn set_selection_range_utf16(
|
|
&mut self,
|
|
start: Utf16CodeUnitLength,
|
|
end: Utf16CodeUnitLength,
|
|
direction: SelectionDirection,
|
|
) {
|
|
self.set_selection_range_utf8(
|
|
self.rope.utf16_offset_to_utf8_offset(start),
|
|
self.rope.utf16_offset_to_utf8_offset(end),
|
|
direction,
|
|
);
|
|
}
|
|
|
|
pub fn set_selection_range_utf8(
|
|
&mut self,
|
|
mut start: Utf8CodeUnitLength,
|
|
mut end: Utf8CodeUnitLength,
|
|
direction: SelectionDirection,
|
|
) {
|
|
let text_end = self.get_content().len_utf8();
|
|
if end > text_end {
|
|
end = text_end;
|
|
}
|
|
if start > end {
|
|
start = end;
|
|
}
|
|
|
|
self.selection_direction = direction;
|
|
|
|
match direction {
|
|
SelectionDirection::None | SelectionDirection::Forward => {
|
|
self.selection_origin = Some(self.rope.utf8_offset_to_rope_index(start));
|
|
self.edit_point = self.rope.utf8_offset_to_rope_index(end);
|
|
},
|
|
SelectionDirection::Backward => {
|
|
self.selection_origin = Some(self.rope.utf8_offset_to_rope_index(end));
|
|
self.edit_point = self.rope.utf8_offset_to_rope_index(start);
|
|
},
|
|
}
|
|
|
|
self.assert_ok_selection();
|
|
}
|
|
|
|
/// This implements step 3 onward from:
|
|
///
|
|
/// - <https://www.w3.org/TR/clipboard-apis/#copy-action>
|
|
/// - <https://www.w3.org/TR/clipboard-apis/#cut-action>
|
|
/// - <https://www.w3.org/TR/clipboard-apis/#paste-action>
|
|
///
|
|
/// Earlier steps should have already been run by the callers.
|
|
pub(crate) fn handle_clipboard_event(
|
|
&mut self,
|
|
clipboard_event: &ClipboardEvent,
|
|
) -> ClipboardEventReaction {
|
|
let event = clipboard_event.upcast::<Event>();
|
|
if !event.IsTrusted() {
|
|
return ClipboardEventReaction::empty();
|
|
}
|
|
|
|
// This step is common to all event types in the specification.
|
|
// Step 3: If the event was not canceled, then
|
|
if event.DefaultPrevented() {
|
|
// Step 4: Else, if the event was canceled
|
|
// Step 4.1: Return false.
|
|
return ClipboardEventReaction::empty();
|
|
}
|
|
|
|
let event_type = event.Type();
|
|
match_domstring_ascii!(event_type,
|
|
"copy" => {
|
|
// These steps are from <https://www.w3.org/TR/clipboard-apis/#copy-action>:
|
|
let selection = self.get_selection_text();
|
|
|
|
// Step 3.1 Copy the selected contents, if any, to the clipboard
|
|
if let Some(text) = selection {
|
|
self.clipboard_provider.set_text(text);
|
|
}
|
|
|
|
// Step 3.2 Fire a clipboard event named clipboardchange
|
|
ClipboardEventReaction::new(ClipboardEventFlags::FireClipboardChangedEvent)
|
|
},
|
|
"cut" => {
|
|
// These steps are from <https://www.w3.org/TR/clipboard-apis/#cut-action>:
|
|
let selection = self.get_selection_text();
|
|
|
|
// Step 3.1 If there is a selection in an editable context where cutting is enabled, then
|
|
let Some(text) = selection else {
|
|
// Step 3.2 Else, if there is no selection or the context is not editable, then
|
|
return ClipboardEventReaction::empty();
|
|
};
|
|
|
|
// Step 3.1.1 Copy the selected contents, if any, to the clipboard
|
|
self.clipboard_provider.set_text(text);
|
|
|
|
// Step 3.1.2 Remove the contents of the selection from the document and collapse the selection.
|
|
self.delete_selection();
|
|
|
|
// Step 3.1.3 Fire a clipboard event named clipboardchange
|
|
// Step 3.1.4 Queue tasks to fire any events that should fire due to the modification.
|
|
ClipboardEventReaction::new(
|
|
ClipboardEventFlags::FireClipboardChangedEvent |
|
|
ClipboardEventFlags::QueueInputEvent,
|
|
)
|
|
.with_input_type(InputType::DeleteByCut)
|
|
},
|
|
"paste" => {
|
|
// These steps are from <https://www.w3.org/TR/clipboard-apis/#paste-action>:
|
|
let Some(data_transfer) = clipboard_event.get_clipboard_data() else {
|
|
return ClipboardEventReaction::empty();
|
|
};
|
|
let Some(drag_data_store) = data_transfer.data_store() else {
|
|
return ClipboardEventReaction::empty();
|
|
};
|
|
|
|
// Step 3.1: If there is a selection or cursor in an editable context where pasting is
|
|
// enabled, then:
|
|
// TODO: Our TextInput always has a selection or an input point. It's likely that this
|
|
// shouldn't be the case when the entry loses the cursor.
|
|
|
|
// Step 3.1.1: Insert the most suitable content found on the clipboard, if any, into the
|
|
// context.
|
|
// TODO: Only text content is currently supported, but other data types should be supported
|
|
// in the future.
|
|
let Some(text_content) =
|
|
drag_data_store
|
|
.iter_item_list()
|
|
.find_map(|item| match item {
|
|
Kind::Text { data, .. } => Some(data.to_string()),
|
|
_ => None,
|
|
})
|
|
else {
|
|
return ClipboardEventReaction::empty();
|
|
};
|
|
if text_content.is_empty() {
|
|
return ClipboardEventReaction::empty();
|
|
}
|
|
|
|
self.insert(&text_content);
|
|
|
|
// Step 3.1.2: Queue tasks to fire any events that should fire due to the
|
|
// modification, see § 5.3 Integration with other scripts and events for details.
|
|
ClipboardEventReaction::new(ClipboardEventFlags::QueueInputEvent)
|
|
.with_text(text_content)
|
|
.with_input_type(InputType::InsertFromPaste)
|
|
},
|
|
_ => ClipboardEventReaction::empty(),)
|
|
}
|
|
|
|
/// <https://w3c.github.io/uievents/#event-type-input>
|
|
pub(crate) fn queue_input_event(
|
|
&self,
|
|
target: &EventTarget,
|
|
data: Option<String>,
|
|
is_composing: IsComposing,
|
|
input_type: InputType,
|
|
) {
|
|
let global = target.global();
|
|
let target = Trusted::new(target);
|
|
global.task_manager().user_interaction_task_source().queue(
|
|
task!(fire_input_event: move || {
|
|
let target = target.root();
|
|
let global = target.global();
|
|
let window = global.as_window();
|
|
let event = InputEvent::new(
|
|
window,
|
|
None,
|
|
atom!("input"),
|
|
true,
|
|
false,
|
|
Some(window),
|
|
0,
|
|
data.map(DOMString::from),
|
|
is_composing.into(),
|
|
input_type.as_str().into(),
|
|
CanGc::deprecated_note(),
|
|
);
|
|
let event = event.upcast::<Event>();
|
|
event.set_composed(true);
|
|
event.fire(&target, CanGc::deprecated_note());
|
|
}),
|
|
);
|
|
}
|
|
}
|