diff options
Diffstat (limited to 'components/script/textinput.rs')
-rw-r--r-- | components/script/textinput.rs | 1073 |
1 files changed, 677 insertions, 396 deletions
diff --git a/components/script/textinput.rs b/components/script/textinput.rs index 144be9dbd78..623085ccd19 100644 --- a/components/script/textinput.rs +++ b/components/script/textinput.rs @@ -1,34 +1,117 @@ /* 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 http://mozilla.org/MPL/2.0/. */ + * 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 clipboard_provider::ClipboardProvider; -use dom::bindings::str::DOMString; -use dom::keyboardevent::KeyboardEvent; -use msg::constellation_msg::{ALT, CONTROL, SHIFT, SUPER}; -use msg::constellation_msg::{Key, KeyModifiers}; +use crate::clipboard_provider::ClipboardProvider; +use crate::dom::bindings::str::DOMString; +use crate::dom::compositionevent::CompositionEvent; +use crate::dom::keyboardevent::KeyboardEvent; +use keyboard_types::{Key, KeyState, Modifiers, ShortcutMatcher}; use std::borrow::ToOwned; -use std::cmp::{max, min}; +use std::cmp::min; use std::default::Default; -use std::ops::Range; +use std::ops::{Add, AddAssign, Range}; use std::usize; use unicode_segmentation::UnicodeSegmentation; -#[derive(Copy, Clone, PartialEq)] +#[derive(Clone, Copy, PartialEq)] pub enum Selection { Selected, - NotSelected + NotSelected, } -#[derive(JSTraceable, PartialEq, Copy, Clone, HeapSizeOf)] +#[derive(Clone, Copy, Debug, JSTraceable, MallocSizeOf, PartialEq)] pub enum SelectionDirection { Forward, Backward, None, } +#[derive(Clone, Copy, Debug, Eq, JSTraceable, MallocSizeOf, Ord, PartialEq, PartialOrd)] +pub struct UTF8Bytes(pub usize); + +impl UTF8Bytes { + pub fn zero() -> UTF8Bytes { + UTF8Bytes(0) + } + + pub fn one() -> UTF8Bytes { + UTF8Bytes(1) + } + + pub fn unwrap_range(byte_range: Range<UTF8Bytes>) -> Range<usize> { + byte_range.start.0..byte_range.end.0 + } + + pub fn saturating_sub(self, other: UTF8Bytes) -> UTF8Bytes { + if self > other { + UTF8Bytes(self.0 - other.0) + } else { + UTF8Bytes::zero() + } + } +} + +impl Add for UTF8Bytes { + type Output = UTF8Bytes; + + fn add(self, other: UTF8Bytes) -> UTF8Bytes { + UTF8Bytes(self.0 + other.0) + } +} + +impl AddAssign for UTF8Bytes { + fn add_assign(&mut self, other: UTF8Bytes) { + *self = UTF8Bytes(self.0 + other.0) + } +} + +trait StrExt { + fn len_utf8(&self) -> UTF8Bytes; +} +impl StrExt for str { + fn len_utf8(&self) -> UTF8Bytes { + UTF8Bytes(self.len()) + } +} + +#[derive(Clone, Copy, Debug, JSTraceable, MallocSizeOf, PartialEq, PartialOrd)] +pub struct UTF16CodeUnits(pub usize); + +impl UTF16CodeUnits { + pub fn zero() -> UTF16CodeUnits { + UTF16CodeUnits(0) + } + + pub fn one() -> UTF16CodeUnits { + UTF16CodeUnits(1) + } + + pub fn saturating_sub(self, other: UTF16CodeUnits) -> UTF16CodeUnits { + if self > other { + UTF16CodeUnits(self.0 - other.0) + } else { + UTF16CodeUnits::zero() + } + } +} + +impl Add for UTF16CodeUnits { + type Output = UTF16CodeUnits; + + fn add(self, other: UTF16CodeUnits) -> UTF16CodeUnits { + UTF16CodeUnits(self.0 + other.0) + } +} + +impl AddAssign for UTF16CodeUnits { + fn add_assign(&mut self, other: UTF16CodeUnits) { + *self = UTF16CodeUnits(self.0 + other.0) + } +} + impl From<DOMString> for SelectionDirection { fn from(direction: DOMString) -> SelectionDirection { match direction.as_ref() { @@ -49,33 +132,61 @@ impl From<SelectionDirection> for DOMString { } } -#[derive(JSTraceable, Copy, Clone, HeapSizeOf, PartialEq)] +#[derive(Clone, Copy, Debug, JSTraceable, MallocSizeOf, PartialEq, PartialOrd)] pub struct TextPoint { /// 0-based line number pub line: usize, - /// 0-based column number in UTF-8 bytes - pub index: usize, + /// 0-based column number in bytes + pub index: UTF8Bytes, +} + +impl TextPoint { + /// Returns a TextPoint constrained to be a valid location within lines + fn constrain_to(&self, lines: &[DOMString]) -> TextPoint { + let line = min(self.line, lines.len() - 1); + + TextPoint { + line, + index: min(self.index, lines[line].len_utf8()), + } + } +} + +#[derive(Clone, Copy, PartialEq)] +pub struct SelectionState { + start: TextPoint, + end: TextPoint, + direction: SelectionDirection, } /// Encapsulated state for handling keyboard input in a single or multiline text input control. -#[derive(JSTraceable, HeapSizeOf)] +#[derive(JSTraceable, MallocSizeOf)] pub struct TextInput<T: ClipboardProvider> { /// Current text input content, split across lines without trailing '\n' lines: Vec<DOMString>, + /// Current cursor input point - pub edit_point: TextPoint, - /// Beginning of selection range with edit_point as end that can span multiple lines. - pub selection_begin: Option<TextPoint>, + edit_point: TextPoint, + + /// 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. + selection_origin: Option<TextPoint>, + selection_direction: SelectionDirection, + /// Is this a multiline input? multiline: bool, - #[ignore_heap_size_of = "Can't easily measure this generic type"] + + #[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 - pub max_length: Option<usize>, - pub min_length: Option<usize>, - pub selection_direction: SelectionDirection, + /// <https://html.spec.whatwg.org/multipage/#attr-fe-maxlength> + max_length: Option<UTF16CodeUnits>, + min_length: Option<UTF16CodeUnits>, + + /// Was last change made by set_content? + was_last_change_by_set_content: bool, } /// Resulting action to be taken by the owner of a text input that is handling an event. @@ -90,87 +201,117 @@ impl Default for TextPoint { fn default() -> TextPoint { TextPoint { line: 0, - index: 0, + index: UTF8Bytes::zero(), } } } /// Control whether this control should allow multiple lines. -#[derive(PartialEq, Eq)] +#[derive(Eq, PartialEq)] pub enum Lines { Single, Multiple, } /// The direction in which to delete a character. -#[derive(PartialEq, Eq, Copy, Clone)] +#[derive(Clone, Copy, Eq, PartialEq)] pub enum Direction { Forward, - Backward + Backward, } - -/// Was the keyboard event accompanied by the standard control modifier, -/// i.e. cmd on Mac OS or ctrl on other platforms. +// Some shortcuts use Cmd on Mac and Control on other systems. #[cfg(target_os = "macos")] -fn is_control_key(mods: KeyModifiers) -> bool { - mods.contains(SUPER) && !mods.contains(CONTROL | ALT) -} - +pub const CMD_OR_CONTROL: Modifiers = Modifiers::META; #[cfg(not(target_os = "macos"))] -fn is_control_key(mods: KeyModifiers) -> bool { - mods.contains(CONTROL) && !mods.contains(SUPER | ALT) -} +pub const CMD_OR_CONTROL: Modifiers = Modifiers::CONTROL; /// The length in bytes of the first n characters in a UTF-8 string. /// /// If the string has fewer than n characters, returns the length of the whole string. -fn len_of_first_n_chars(text: &str, n: usize) -> usize { +/// If n is 0, returns 0 +fn len_of_first_n_chars(text: &str, n: usize) -> UTF8Bytes { match text.char_indices().take(n).last() { - Some((index, ch)) => index + ch.len_utf8(), - None => 0 + Some((index, ch)) => UTF8Bytes(index + ch.len_utf8()), + None => UTF8Bytes::zero(), } } -/// The length in bytes of the first n code units a string when encoded in UTF-16. +/// 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: &str, n: usize) -> usize { - let mut utf8_len = 0; - let mut utf16_len = 0; +fn len_of_first_n_code_units(text: &str, n: UTF16CodeUnits) -> UTF8Bytes { + let mut utf8_len = UTF8Bytes::zero(); + let mut utf16_len = UTF16CodeUnits::zero(); for c in text.chars() { - utf16_len += c.len_utf16(); + utf16_len += UTF16CodeUnits(c.len_utf16()); if utf16_len > n { break; } - utf8_len += c.len_utf8(); + utf8_len += UTF8Bytes(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, max_length: Option<usize>, - min_length: Option<usize>, - selection_direction: SelectionDirection) -> TextInput<T> { + pub fn new( + lines: Lines, + initial: DOMString, + clipboard_provider: T, + max_length: Option<UTF16CodeUnits>, + min_length: Option<UTF16CodeUnits>, + selection_direction: SelectionDirection, + ) -> TextInput<T> { let mut i = TextInput { - lines: vec!(), + lines: vec![], edit_point: Default::default(), - selection_begin: None, + selection_origin: None, multiline: lines == Lines::Multiple, clipboard_provider: clipboard_provider, max_length: max_length, min_length: min_length, selection_direction: selection_direction, + was_last_change_by_set_content: true, }; i.set_content(initial); i } + pub fn edit_point(&self) -> TextPoint { + self.edit_point + } + + pub fn selection_origin(&self) -> Option<TextPoint> { + 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) -> TextPoint { + 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<UTF16CodeUnits>) { + self.max_length = length; + } + + pub fn set_min_length(&mut self, length: Option<UTF16CodeUnits>) { + self.min_length = length; + } + + /// Was last edit made by set_content? + pub fn was_last_change_by_set_content(&self) -> bool { + self.was_last_change_by_set_content + } + /// Remove a character at the current editing point pub fn delete_char(&mut self, dir: Direction) { - if self.selection_begin.is_none() || self.selection_begin == Some(self.edit_point) { + if self.selection_origin.is_none() || self.selection_origin == Some(self.edit_point) { self.adjust_horizontal_by_one(dir, Selection::Selected); } self.replace_selection(DOMString::new()); @@ -183,147 +324,198 @@ impl<T: ClipboardProvider> TextInput<T> { /// Insert a string at the current editing point pub fn insert_string<S: Into<String>>(&mut self, s: S) { - if self.selection_begin.is_none() { - self.selection_begin = Some(self.edit_point); + if self.selection_origin.is_none() { + self.selection_origin = Some(self.edit_point); } self.replace_selection(DOMString::from(s.into())); } - pub fn get_sorted_selection(&self) -> Option<(TextPoint, TextPoint)> { - self.selection_begin.map(|begin| { - let end = self.edit_point; + /// 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) -> TextPoint { + match self.selection_direction { + SelectionDirection::None | SelectionDirection::Forward => { + self.selection_origin_or_edit_point() + }, + SelectionDirection::Backward => self.edit_point, + } + } - if begin.line < end.line || (begin.line == end.line && begin.index < end.index) { - (begin, end) - } else { - (end, begin) - } - }) + /// The byte offset of the selection_start() + pub fn selection_start_offset(&self) -> UTF8Bytes { + self.text_point_to_offset(&self.selection_start()) } - // Check that the selection is valid. - fn assert_ok_selection(&self) { - if let Some(begin) = self.selection_begin { - debug_assert!(begin.line < self.lines.len()); - debug_assert!(begin.index <= self.lines[begin.line].len()); + /// 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) -> TextPoint { + match self.selection_direction { + SelectionDirection::None | SelectionDirection::Forward => self.edit_point, + SelectionDirection::Backward => self.selection_origin_or_edit_point(), } - debug_assert!(self.edit_point.line < self.lines.len()); - debug_assert!(self.edit_point.index <= self.lines[self.edit_point.line].len()); } - /// Return the selection range as UTF-8 byte offsets from the start of the content. + /// The byte offset of the selection_end() + pub fn selection_end_offset(&self) -> UTF8Bytes { + self.text_point_to_offset(&self.selection_end()) + } + + /// Whether or not there is an active selection (the selection may be zero-length) + #[inline] + pub fn has_selection(&self) -> bool { + self.selection_origin.is_some() + } + + /// Returns a tuple of (start, end) giving the bounds of the current selection. start is always + /// less than or equal to end. + pub fn sorted_selection_bounds(&self) -> (TextPoint, TextPoint) { + (self.selection_start(), self.selection_end()) + } + + /// Return the selection range as byte offsets from the start of the content. /// - /// If there is no selection, returns an empty range at the insertion point. - pub fn get_absolute_selection_range(&self) -> Range<usize> { - match self.get_sorted_selection() { - Some((begin, end)) => self.get_absolute_point_for_text_point(&begin) .. - self.get_absolute_point_for_text_point(&end), - None => { - let insertion_point = self.get_absolute_insertion_point(); - insertion_point .. insertion_point + /// If there is no selection, returns an empty range at the edit point. + pub fn sorted_selection_offsets_range(&self) -> Range<UTF8Bytes> { + self.selection_start_offset()..self.selection_end_offset() + } + + /// The state of the current selection. Can be used to compare whether selection state has changed. + pub 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 + ); + if let Some(begin) = self.selection_origin { + debug_assert!(begin.line < self.lines.len()); + debug_assert!(begin.index <= self.lines[begin.line].len_utf8()); + + match self.selection_direction { + SelectionDirection::None | SelectionDirection::Forward => { + debug_assert!(begin <= self.edit_point) + }, + + SelectionDirection::Backward => debug_assert!(self.edit_point <= begin), } } + + debug_assert!(self.edit_point.line < self.lines.len()); + debug_assert!(self.edit_point.index <= self.lines[self.edit_point.line].len_utf8()); } pub fn get_selection_text(&self) -> Option<String> { let text = self.fold_selection_slices(String::new(), |s, slice| s.push_str(slice)); if text.is_empty() { - return None + return None; } Some(text) } /// The length of the selected text in UTF-16 code units. - fn selection_utf16_len(&self) -> usize { - self.fold_selection_slices(0usize, - |len, slice| *len += slice.chars().map(char::len_utf16).sum()) + fn selection_utf16_len(&self) -> UTF16CodeUnits { + self.fold_selection_slices(UTF16CodeUnits::zero(), |len, slice| { + *len += UTF16CodeUnits(slice.chars().map(char::len_utf16).sum::<usize>()) + }) } /// Run the callback on a series of slices that, concatenated, make up the selected text. /// /// The accumulator `acc` can be mutated by the callback, and will be returned at the end. fn fold_selection_slices<B, F: FnMut(&mut B, &str)>(&self, mut acc: B, mut f: F) -> B { - match self.get_sorted_selection() { - Some((begin, end)) if begin.line == end.line => { - f(&mut acc, &self.lines[begin.line][begin.index..end.index]) - } - Some((begin, end)) => { - f(&mut acc, &self.lines[begin.line][begin.index..]); - for line in &self.lines[begin.line + 1 .. end.line] { + if self.has_selection() { + let (start, end) = self.sorted_selection_bounds(); + let UTF8Bytes(start_offset) = start.index; + let UTF8Bytes(end_offset) = end.index; + + if start.line == end.line { + f(&mut acc, &self.lines[start.line][start_offset..end_offset]) + } else { + f(&mut acc, &self.lines[start.line][start_offset..]); + for line in &self.lines[start.line + 1..end.line] { f(&mut acc, "\n"); f(&mut acc, line); } f(&mut acc, "\n"); - f(&mut acc, &self.lines[end.line][..end.index]) + f(&mut acc, &self.lines[end.line][..end_offset]) } - None => {} } + acc } pub fn replace_selection(&mut self, insert: DOMString) { - if let Some((begin, end)) = self.get_sorted_selection() { - let allowed_to_insert_count = if let Some(max_length) = self.max_length { - let len_after_selection_replaced = self.utf16_len() - self.selection_utf16_len(); - if len_after_selection_replaced >= max_length { - // If, after deleting the selection, the len is still greater than the max - // length, then don't delete/insert anything - return - } + if !self.has_selection() { + return; + } - max_length - len_after_selection_replaced - } else { - usize::MAX - }; + let allowed_to_insert_count = if let Some(max_length) = self.max_length { + let len_after_selection_replaced = + self.utf16_len().saturating_sub(self.selection_utf16_len()); + max_length.saturating_sub(len_after_selection_replaced) + } else { + UTF16CodeUnits(usize::MAX) + }; - let last_char_index = len_of_first_n_code_units(&*insert, allowed_to_insert_count); - let chars_to_insert = &insert[..last_char_index]; + let UTF8Bytes(last_char_index) = + len_of_first_n_code_units(&*insert, allowed_to_insert_count); + let to_insert = &insert[..last_char_index]; - self.clear_selection(); + let (start, end) = self.sorted_selection_bounds(); + let UTF8Bytes(start_offset) = start.index; + let UTF8Bytes(end_offset) = end.index; - let new_lines = { - let prefix = &self.lines[begin.line][..begin.index]; - let suffix = &self.lines[end.line][end.index..]; - let lines_prefix = &self.lines[..begin.line]; - let lines_suffix = &self.lines[end.line + 1..]; + let new_lines = { + let prefix = &self.lines[start.line][..start_offset]; + let suffix = &self.lines[end.line][end_offset..]; + let lines_prefix = &self.lines[..start.line]; + let lines_suffix = &self.lines[end.line + 1..]; - let mut insert_lines = if self.multiline { - chars_to_insert.split('\n').map(|s| DOMString::from(s)).collect() - } else { - vec!(DOMString::from(chars_to_insert)) - }; + let mut insert_lines = if self.multiline { + to_insert.split('\n').map(|s| DOMString::from(s)).collect() + } else { + vec![DOMString::from(to_insert)] + }; - // FIXME(ajeffrey): effecient append for DOMStrings - let mut new_line = prefix.to_owned(); + // FIXME(ajeffrey): effecient append for DOMStrings + let mut new_line = prefix.to_owned(); - new_line.push_str(&insert_lines[0]); - insert_lines[0] = DOMString::from(new_line); + new_line.push_str(&insert_lines[0]); + insert_lines[0] = DOMString::from(new_line); - let last_insert_lines_index = insert_lines.len() - 1; - self.edit_point.index = insert_lines[last_insert_lines_index].len(); - self.edit_point.line = begin.line + last_insert_lines_index; + let last_insert_lines_index = insert_lines.len() - 1; + self.edit_point.index = insert_lines[last_insert_lines_index].len_utf8(); + self.edit_point.line = start.line + last_insert_lines_index; - // FIXME(ajeffrey): effecient append for DOMStrings - insert_lines[last_insert_lines_index].push_str(suffix); + // FIXME(ajeffrey): effecient append for DOMStrings + insert_lines[last_insert_lines_index].push_str(suffix); - let mut new_lines = vec!(); - new_lines.extend_from_slice(lines_prefix); - new_lines.extend_from_slice(&insert_lines); - new_lines.extend_from_slice(lines_suffix); - new_lines - }; + let mut new_lines = vec![]; + new_lines.extend_from_slice(lines_prefix); + new_lines.extend_from_slice(&insert_lines); + new_lines.extend_from_slice(lines_suffix); + new_lines + }; - self.lines = new_lines; - } + self.lines = new_lines; + self.was_last_change_by_set_content = false; + self.clear_selection(); self.assert_ok_selection(); } - /// Return the length in UTF-8 bytes of the current line under the editing point. - pub fn current_line_length(&self) -> usize { - self.lines[self.edit_point.line].len() + /// Return the length in bytes of the current line under the editing point. + pub fn current_line_length(&self) -> UTF8Bytes { + self.lines[self.edit_point.line].len_utf8() } - /// Adjust the editing point position by a given of lines. The resulting column is + /// Adjust the editing point position by a given number of lines. The resulting column is /// as close to the original column position as possible. pub fn adjust_vertical(&mut self, adjust: isize, select: Selection) { if !self.multiline { @@ -331,8 +523,8 @@ impl<T: ClipboardProvider> TextInput<T> { } if select == Selection::Selected { - if self.selection_begin.is_none() { - self.selection_begin = Some(self.edit_point); + if self.selection_origin.is_none() { + self.selection_origin = Some(self.edit_point); } } else { self.clear_selection(); @@ -343,100 +535,167 @@ impl<T: ClipboardProvider> TextInput<T> { let target_line: isize = self.edit_point.line as isize + adjust; if target_line < 0 { - self.edit_point.index = 0; self.edit_point.line = 0; + self.edit_point.index = UTF8Bytes::zero(); + if self.selection_origin.is_some() && + (self.selection_direction == SelectionDirection::None || + self.selection_direction == SelectionDirection::Forward) + { + self.selection_origin = Some(TextPoint { + line: 0, + index: UTF8Bytes::zero(), + }); + } return; } else if target_line as usize >= self.lines.len() { self.edit_point.line = self.lines.len() - 1; self.edit_point.index = self.current_line_length(); + if self.selection_origin.is_some() && + (self.selection_direction == SelectionDirection::Backward) + { + self.selection_origin = Some(self.edit_point); + } return; } - - let col = self.lines[self.edit_point.line][..self.edit_point.index].chars().count(); - + let UTF8Bytes(edit_index) = self.edit_point.index; + let col = self.lines[self.edit_point.line][..edit_index] + .chars() + .count(); self.edit_point.line = target_line as usize; + // NOTE: this adjusts to the nearest complete Unicode codepoint, rather than grapheme cluster self.edit_point.index = len_of_first_n_chars(&self.lines[self.edit_point.line], col); + if let Some(origin) = self.selection_origin { + if ((self.selection_direction == SelectionDirection::None || + self.selection_direction == SelectionDirection::Forward) && + self.edit_point <= origin) || + (self.selection_direction == SelectionDirection::Backward && + origin <= self.edit_point) + { + self.selection_origin = Some(self.edit_point); + } + } self.assert_ok_selection(); } /// Adjust the editing point position by a given number of bytes. If the adjustment /// requested is larger than is available in the current line, the editing point is /// adjusted vertically and the process repeats with the remaining adjustment requested. - pub fn adjust_horizontal(&mut self, adjust: isize, select: Selection) { - let direction = if adjust >= 0 { Direction::Forward } else { Direction::Backward }; + pub fn adjust_horizontal( + &mut self, + adjust: UTF8Bytes, + direction: Direction, + select: Selection, + ) { if self.adjust_selection_for_horizontal_change(direction, select) { - return + return; } - self.perform_horizontal_adjustment(adjust, select); + self.perform_horizontal_adjustment(adjust, direction, select); } + /// Adjust the editing point position by exactly one grapheme cluster. If the edit point + /// is at the beginning of the line and the direction is "Backward" or the edit point is at + /// the end of the line and the direction is "Forward", a vertical adjustment is made pub fn adjust_horizontal_by_one(&mut self, direction: Direction, select: Selection) { if self.adjust_selection_for_horizontal_change(direction, select) { - return + return; } let adjust = { let current_line = &self.lines[self.edit_point.line]; - match direction { - Direction::Forward => { - match current_line[self.edit_point.index..].graphemes(true).next() { - Some(c) => c.len() as isize, - None => 1, // Going to the next line is a "one byte" offset - } - } - Direction::Backward => { - match current_line[..self.edit_point.index].graphemes(true).next_back() { - Some(c) => -(c.len() as isize), - None => -1, // Going to the previous line is a "one byte" offset - } - } + let UTF8Bytes(current_offset) = self.edit_point.index; + let next_ch = match direction { + Direction::Forward => current_line[current_offset..].graphemes(true).next(), + Direction::Backward => current_line[..current_offset].graphemes(true).next_back(), + }; + match next_ch { + Some(c) => UTF8Bytes(c.len() as usize), + None => UTF8Bytes::one(), // Going to the next line is a "one byte" offset } }; - self.perform_horizontal_adjustment(adjust, select); + self.perform_horizontal_adjustment(adjust, direction, select); } /// Return whether to cancel the caret move - fn adjust_selection_for_horizontal_change(&mut self, adjust: Direction, select: Selection) - -> bool { + fn adjust_selection_for_horizontal_change( + &mut self, + adjust: Direction, + select: Selection, + ) -> bool { if select == Selection::Selected { - if self.selection_begin.is_none() { - self.selection_begin = Some(self.edit_point); + if self.selection_origin.is_none() { + self.selection_origin = Some(self.edit_point); } } else { - if let Some((begin, end)) = self.get_sorted_selection() { + if self.has_selection() { self.edit_point = match adjust { - Direction::Backward => begin, - Direction::Forward => end, + Direction::Backward => self.selection_start(), + Direction::Forward => self.selection_end(), }; self.clear_selection(); - return true + return true; } } false } - fn perform_horizontal_adjustment(&mut self, adjust: isize, select: Selection) { - if adjust < 0 { - let remaining = self.edit_point.index; - if adjust.abs() as usize > remaining && self.edit_point.line > 0 { - self.adjust_vertical(-1, select); - self.edit_point.index = self.current_line_length(); - self.adjust_horizontal(adjust + remaining as isize + 1, select); - } else { - self.edit_point.index = max(0, self.edit_point.index as isize + adjust) as usize; - } + /// 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 { - let remaining = self.current_line_length() - self.edit_point.index; - if adjust as usize > remaining && self.lines.len() > self.edit_point.line + 1 { - self.adjust_vertical(1, select); - self.edit_point.index = 0; - // one shift is consumed by the change of line, hence the -1 - self.adjust_horizontal(adjust - remaining as isize - 1, select); - } else { - self.edit_point.index = min(self.current_line_length(), - self.edit_point.index + adjust as usize); - } + SelectionDirection::Forward } + } + + fn perform_horizontal_adjustment( + &mut self, + adjust: UTF8Bytes, + direction: Direction, + select: Selection, + ) { + match direction { + Direction::Backward => { + let remaining = self.edit_point.index; + if adjust > remaining && self.edit_point.line > 0 { + self.adjust_vertical(-1, select); + self.edit_point.index = self.current_line_length(); + // one shift is consumed by the change of line, hence the -1 + self.adjust_horizontal( + adjust.saturating_sub(remaining + UTF8Bytes::one()), + direction, + select, + ); + } else { + self.edit_point.index = remaining.saturating_sub(adjust); + } + }, + Direction::Forward => { + let remaining = self + .current_line_length() + .saturating_sub(self.edit_point.index); + if adjust > remaining && self.lines.len() > self.edit_point.line + 1 { + self.adjust_vertical(1, select); + self.edit_point.index = UTF8Bytes::zero(); + // one shift is consumed by the change of line, hence the -1 + self.adjust_horizontal( + adjust.saturating_sub(remaining + UTF8Bytes::one()), + direction, + select, + ); + } else { + self.edit_point.index = + min(self.current_line_length(), self.edit_point.index + adjust); + } + }, + }; + self.update_selection_direction(); self.assert_ok_selection(); } @@ -452,266 +711,273 @@ impl<T: ClipboardProvider> TextInput<T> { /// Select all text in the input control. pub fn select_all(&mut self) { - self.selection_begin = Some(TextPoint { + self.selection_origin = Some(TextPoint { line: 0, - index: 0, + index: UTF8Bytes::zero(), }); let last_line = self.lines.len() - 1; self.edit_point.line = last_line; - self.edit_point.index = self.lines[last_line].len(); + self.edit_point.index = self.lines[last_line].len_utf8(); + self.selection_direction = SelectionDirection::Forward; self.assert_ok_selection(); } /// Remove the current selection. pub fn clear_selection(&mut self) { - self.selection_begin = None; + 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 fn clear_selection_to_limit(&mut self, direction: Direction) { + self.clear_selection(); + self.adjust_horizontal_to_limit(direction, Selection::NotSelected); } pub fn adjust_horizontal_by_word(&mut self, direction: Direction, select: Selection) { if self.adjust_selection_for_horizontal_change(direction, select) { - return + return; } - let shift_increment: isize = { - let input: &str; + let shift_increment: UTF8Bytes = { + let current_index = self.edit_point.index; + let current_line = self.edit_point.line; + let mut newline_adjustment = UTF8Bytes::zero(); + let mut shift_temp = UTF8Bytes::zero(); match direction { Direction::Backward => { - let remaining = self.edit_point.index; - let current_line = self.edit_point.line; - let mut newline_adjustment = 0; - if remaining == 0 && current_line > 0 { - input = &self - .lines[current_line-1]; - newline_adjustment = 1; + let input: &str; + if current_index == UTF8Bytes::zero() && current_line > 0 { + input = &self.lines[current_line - 1]; + newline_adjustment = UTF8Bytes::one(); } else { - input = &self - .lines[current_line] - [..remaining]; + let UTF8Bytes(remaining) = current_index; + input = &self.lines[current_line][..remaining]; } let mut iter = input.split_word_bounds().rev(); - let mut shift_temp: isize = 0; loop { match iter.next() { None => break, Some(x) => { - shift_temp += - (x.len() as isize); + shift_temp += UTF8Bytes(x.len() as usize); if x.chars().any(|x| x.is_alphabetic() || x.is_numeric()) { break; } - } + }, } } - shift_temp - newline_adjustment - } + }, Direction::Forward => { - let remaining = self.current_line_length() - self.edit_point.index; - let current_line = self.edit_point.line; - let mut newline_adjustment = 0; - if remaining == 0 && self.lines.len() > self.edit_point.line + 1 { - input = &self - .lines[current_line + 1]; - newline_adjustment = 1; + let input: &str; + let remaining = self.current_line_length().saturating_sub(current_index); + if remaining == UTF8Bytes::zero() && self.lines.len() > self.edit_point.line + 1 + { + input = &self.lines[current_line + 1]; + newline_adjustment = UTF8Bytes::one(); } else { - input = &self - .lines[current_line] - [self.edit_point.index..]; + let UTF8Bytes(current_offset) = current_index; + input = &self.lines[current_line][current_offset..]; } let mut iter = input.split_word_bounds(); - let mut shift_temp: isize = 0; loop { match iter.next() { None => break, Some(x) => { - shift_temp += x.len() as isize; + shift_temp += UTF8Bytes(x.len() as usize); if x.chars().any(|x| x.is_alphabetic() || x.is_numeric()) { break; } - } + }, } } - shift_temp + newline_adjustment - } - } + }, + }; + + shift_temp + newline_adjustment }; - self.adjust_horizontal(shift_increment, select); + self.adjust_horizontal(shift_increment, direction, select); } pub fn adjust_horizontal_to_line_end(&mut self, direction: Direction, select: Selection) { if self.adjust_selection_for_horizontal_change(direction, select) { - return + return; } - let shift: isize = { + let shift: usize = { let current_line = &self.lines[self.edit_point.line]; + let UTF8Bytes(current_offset) = self.edit_point.index; match direction { - Direction::Backward => { - - (current_line[..self.edit_point.index].len() as isize) - }, - Direction::Forward => { - current_line[self.edit_point.index..].len() as isize - } + Direction::Backward => current_line[..current_offset].len(), + Direction::Forward => current_line[current_offset..].len(), } }; - self.perform_horizontal_adjustment(shift, select); + self.perform_horizontal_adjustment(UTF8Bytes(shift), direction, select); } pub fn adjust_horizontal_to_limit(&mut self, direction: Direction, select: Selection) { if self.adjust_selection_for_horizontal_change(direction, select) { - return + return; } match direction { Direction::Backward => { self.edit_point.line = 0; - self.edit_point.index = 0; + self.edit_point.index = UTF8Bytes::zero(); }, Direction::Forward => { self.edit_point.line = &self.lines.len() - 1; - self.edit_point.index = (&self.lines[&self.lines.len() - 1]).len(); - } + self.edit_point.index = (&self.lines[&self.lines.len() - 1]).len_utf8(); + }, } } /// Process a given `KeyboardEvent` and return an action for the caller to execute. pub fn handle_keydown(&mut self, event: &KeyboardEvent) -> KeyReaction { - if let Some(key) = event.get_key() { - self.handle_keydown_aux(event.printable(), key, event.get_key_modifiers()) - } else { - KeyReaction::Nothing - } + let key = event.key(); + let mods = event.modifiers(); + self.handle_keydown_aux(key, mods, cfg!(target_os = "macos")) } - pub fn handle_keydown_aux(&mut self, - printable: Option<char>, - key: Key, - mods: KeyModifiers) -> KeyReaction { - let maybe_select = if mods.contains(SHIFT) { Selection::Selected } else { Selection::NotSelected }; - match (printable, key) { - (_, Key::B) if mods.contains(CONTROL | ALT) => { + // 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 + }; + mods.remove(Modifiers::SHIFT); + ShortcutMatcher::new(KeyState::Down, key.clone(), mods) + .shortcut(Modifiers::CONTROL | Modifiers::ALT, 'B', || { self.adjust_horizontal_by_word(Direction::Backward, maybe_select); KeyReaction::RedrawSelection - }, - (_, Key::F) if mods.contains(CONTROL | ALT) => { + }) + .shortcut(Modifiers::CONTROL | Modifiers::ALT, 'F', || { self.adjust_horizontal_by_word(Direction::Forward, maybe_select); KeyReaction::RedrawSelection - }, - (_, Key::A) if mods.contains(CONTROL | ALT) => { + }) + .shortcut(Modifiers::CONTROL | Modifiers::ALT, 'A', || { self.adjust_horizontal_to_line_end(Direction::Backward, maybe_select); KeyReaction::RedrawSelection - }, - (_, Key::E) if mods.contains(CONTROL | ALT) => { + }) + .shortcut(Modifiers::CONTROL | Modifiers::ALT, 'E', || { self.adjust_horizontal_to_line_end(Direction::Forward, maybe_select); KeyReaction::RedrawSelection - }, - #[cfg(target_os = "macos")] - (None, Key::A) if mods == CONTROL => { + }) + .optional_shortcut(macos, Modifiers::CONTROL, 'A', || { self.adjust_horizontal_to_line_end(Direction::Backward, maybe_select); KeyReaction::RedrawSelection - }, - #[cfg(target_os = "macos")] - (None, Key::E) if mods == CONTROL => { + }) + .optional_shortcut(macos, Modifiers::CONTROL, 'E', || { self.adjust_horizontal_to_line_end(Direction::Forward, maybe_select); KeyReaction::RedrawSelection - }, - (Some('a'), _) if is_control_key(mods) => { + }) + .shortcut(CMD_OR_CONTROL, 'A', || { self.select_all(); KeyReaction::RedrawSelection - }, - (Some('c'), _) if is_control_key(mods) => { + }) + .shortcut(CMD_OR_CONTROL, 'X', || { if let Some(text) = self.get_selection_text() { self.clipboard_provider.set_clipboard_contents(text); + self.delete_char(Direction::Backward); } KeyReaction::DispatchInput - }, - (Some('v'), _) if is_control_key(mods) => { + }) + .shortcut(CMD_OR_CONTROL, 'C', || { + if let Some(text) = self.get_selection_text() { + self.clipboard_provider.set_clipboard_contents(text); + } + KeyReaction::DispatchInput + }) + .shortcut(CMD_OR_CONTROL, 'V', || { let contents = self.clipboard_provider.clipboard_contents(); self.insert_string(contents); KeyReaction::DispatchInput - }, - (Some(c), _) => { - self.insert_char(c); - KeyReaction::DispatchInput - }, - #[cfg(target_os = "macos")] - (None, Key::Home) => { - KeyReaction::RedrawSelection - }, - #[cfg(target_os = "macos")] - (None, Key::End) => { - KeyReaction::RedrawSelection - }, - (None, Key::Delete) => { + }) + .shortcut(Modifiers::empty(), Key::Delete, || { self.delete_char(Direction::Forward); KeyReaction::DispatchInput - }, - (None, Key::Backspace) => { + }) + .shortcut(Modifiers::empty(), Key::Backspace, || { self.delete_char(Direction::Backward); KeyReaction::DispatchInput - }, - #[cfg(target_os = "macos")] - (None, Key::Left) if mods.contains(SUPER) => { + }) + .optional_shortcut(macos, Modifiers::META, Key::ArrowLeft, || { self.adjust_horizontal_to_line_end(Direction::Backward, maybe_select); KeyReaction::RedrawSelection - }, - #[cfg(target_os = "macos")] - (None, Key::Right) if mods.contains(SUPER) => { + }) + .optional_shortcut(macos, Modifiers::META, Key::ArrowRight, || { self.adjust_horizontal_to_line_end(Direction::Forward, maybe_select); KeyReaction::RedrawSelection - }, - #[cfg(target_os = "macos")] - (None, Key::Up) if mods.contains(SUPER) => { + }) + .optional_shortcut(macos, Modifiers::META, Key::ArrowUp, || { self.adjust_horizontal_to_limit(Direction::Backward, maybe_select); KeyReaction::RedrawSelection - }, - #[cfg(target_os = "macos")] - (None, Key::Down) if mods.contains(SUPER) => { + }) + .optional_shortcut(macos, Modifiers::META, Key::ArrowDown, || { self.adjust_horizontal_to_limit(Direction::Forward, maybe_select); KeyReaction::RedrawSelection - }, - (None, Key::Left) if mods.contains(ALT) => { + }) + .shortcut(Modifiers::ALT, Key::ArrowLeft, || { self.adjust_horizontal_by_word(Direction::Backward, maybe_select); KeyReaction::RedrawSelection - }, - (None, Key::Right) if mods.contains(ALT) => { + }) + .shortcut(Modifiers::ALT, Key::ArrowRight, || { self.adjust_horizontal_by_word(Direction::Forward, maybe_select); KeyReaction::RedrawSelection - }, - (None, Key::Left) => { + }) + .shortcut(Modifiers::empty(), Key::ArrowLeft, || { self.adjust_horizontal_by_one(Direction::Backward, maybe_select); KeyReaction::RedrawSelection - }, - (None, Key::Right) => { + }) + .shortcut(Modifiers::empty(), Key::ArrowRight, || { self.adjust_horizontal_by_one(Direction::Forward, maybe_select); KeyReaction::RedrawSelection - }, - (None, Key::Up) => { + }) + .shortcut(Modifiers::empty(), Key::ArrowUp, || { self.adjust_vertical(-1, maybe_select); KeyReaction::RedrawSelection - }, - (None, Key::Down) => { + }) + .shortcut(Modifiers::empty(), Key::ArrowDown, || { self.adjust_vertical(1, maybe_select); KeyReaction::RedrawSelection - }, - (None, Key::Enter) | (None, Key::KpEnter) => self.handle_return(), - (None, Key::Home) => { - self.edit_point.index = 0; + }) + .shortcut(Modifiers::empty(), Key::Enter, || self.handle_return()) + .optional_shortcut(macos, Modifiers::empty(), Key::Home, || { + self.edit_point.index = UTF8Bytes::zero(); KeyReaction::RedrawSelection - }, - (None, Key::End) => { + }) + .optional_shortcut(macos, Modifiers::empty(), Key::End, || { self.edit_point.index = self.current_line_length(); self.assert_ok_selection(); KeyReaction::RedrawSelection - }, - (None, Key::PageUp) => { + }) + .shortcut(Modifiers::empty(), Key::PageUp, || { self.adjust_vertical(-28, maybe_select); KeyReaction::RedrawSelection - }, - (None, Key::PageDown) => { + }) + .shortcut(Modifiers::empty(), Key::PageDown, || { self.adjust_vertical(28, maybe_select); KeyReaction::RedrawSelection - }, - _ => KeyReaction::Nothing, - } + }) + .otherwise(|| { + if let Key::Character(ref c) = key { + self.insert_string(c.as_str()); + return KeyReaction::DispatchInput; + } + KeyReaction::Nothing + }) + .unwrap() + } + + pub fn handle_compositionend(&mut self, event: &CompositionEvent) -> KeyReaction { + self.insert_string(event.data()); + KeyReaction::DispatchInput } /// Whether the content is empty. @@ -720,20 +986,27 @@ impl<T: ClipboardProvider> TextInput<T> { } /// The length of the content in bytes. - pub fn len(&self) -> usize { - self.lines.iter().fold(0, |m, l| { - m + l.len() + 1 // + 1 for the '\n' - }) - 1 + pub fn len_utf8(&self) -> UTF8Bytes { + self.lines + .iter() + .fold(UTF8Bytes::zero(), |m, l| { + m + l.len_utf8() + UTF8Bytes::one() // + 1 for the '\n' + }) + .saturating_sub(UTF8Bytes::one()) } - /// The length of the content in bytes. - pub fn utf16_len(&self) -> usize { - self.lines.iter().fold(0, |m, l| { - m + l.chars().map(char::len_utf16).sum::<usize>() + 1 // + 1 for the '\n' - }) - 1 + /// The total number of code units required to encode the content in utf16. + pub fn utf16_len(&self) -> UTF16CodeUnits { + self.lines + .iter() + .fold(UTF16CodeUnits::zero(), |m, l| { + m + UTF16CodeUnits(l.chars().map(char::len_utf16).sum::<usize>() + 1) + // + 1 for the '\n' + }) + .saturating_sub(UTF16CodeUnits::one()) } - /// The length of the content in chars. + /// The length of the content in Unicode code points. pub fn char_count(&self) -> usize { self.lines.iter().fold(0, |m, l| { m + l.chars().count() + 1 // + 1 for the '\n' @@ -752,65 +1025,82 @@ impl<T: ClipboardProvider> TextInput<T> { DOMString::from(content) } + /// Get a reference to the contents of a single-line text input. Panics if self is a multiline input. + pub fn single_line_content(&self) -> &DOMString { + assert!(!self.multiline); + &self.lines[0] + } + /// 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. pub fn set_content(&mut self, content: DOMString) { self.lines = if self.multiline { - content.split('\n').map(DOMString::from).collect() + // https://html.spec.whatwg.org/multipage/#textarea-line-break-normalisation-transformation + content + .replace("\r\n", "\n") + .split(|c| c == '\n' || c == '\r') + .map(DOMString::from) + .collect() } else { - vec!(content) + vec![content] }; - self.edit_point.line = min(self.edit_point.line, self.lines.len() - 1); - self.edit_point.index = min(self.edit_point.index, self.current_line_length()); - self.selection_begin = None; - self.assert_ok_selection(); - } - /// Get the insertion point as a byte offset from the start of the content. - pub fn get_absolute_insertion_point(&self) -> usize { - self.get_absolute_point_for_text_point(&self.edit_point) + self.was_last_change_by_set_content = true; + self.edit_point = self.edit_point.constrain_to(&self.lines); + + if let Some(origin) = self.selection_origin { + self.selection_origin = Some(origin.constrain_to(&self.lines)); + } + self.assert_ok_selection(); } /// Convert a TextPoint into a byte offset from the start of the content. - pub fn get_absolute_point_for_text_point(&self, text_point: &TextPoint) -> usize { - self.lines.iter().enumerate().fold(0, |acc, (i, val)| { - if i < text_point.line { - acc + val.len() + 1 // +1 for the \n - } else { - acc - } - }) + text_point.index + fn text_point_to_offset(&self, text_point: &TextPoint) -> UTF8Bytes { + self.lines + .iter() + .enumerate() + .fold(UTF8Bytes::zero(), |acc, (i, val)| { + if i < text_point.line { + acc + val.len_utf8() + UTF8Bytes::one() // +1 for the \n + } else { + acc + } + }) + + text_point.index } /// Convert a byte offset from the start of the content into a TextPoint. - pub fn get_text_point_for_absolute_point(&self, abs_point: usize) -> TextPoint { + fn offset_to_text_point(&self, abs_point: UTF8Bytes) -> TextPoint { let mut index = abs_point; let mut line = 0; - let last_line_idx = self.lines.len() - 1; - self.lines.iter().enumerate().fold(0, |acc, (i, val)| { - if i != last_line_idx { - let line_end = max(val.len(), 1); - let new_acc = acc + line_end; - if abs_point > new_acc && index > line_end { - index -= line_end + 1; - line += 1; + self.lines + .iter() + .enumerate() + .fold(UTF8Bytes::zero(), |acc, (i, val)| { + if i != last_line_idx { + let line_end = val.len_utf8(); + let new_acc = acc + line_end + UTF8Bytes::one(); + if abs_point >= new_acc && index > line_end { + index = index.saturating_sub(line_end + UTF8Bytes::one()); + line += 1; + } + new_acc + } else { + acc } - new_acc - } else { - acc - } - }); + }); TextPoint { - line: line, index: index + line: line, + index: index, } } - pub fn set_selection_range(&mut self, start: u32, end: u32) { - let mut start = start as usize; - let mut end = end as usize; - let text_end = self.get_content().len(); + pub fn set_selection_range(&mut self, start: u32, end: u32, direction: SelectionDirection) { + let mut start = UTF8Bytes(start as usize); + let mut end = UTF8Bytes(end as usize); + let text_end = self.get_content().len_utf8(); if end > text_end { end = text_end; @@ -819,36 +1109,27 @@ impl<T: ClipboardProvider> TextInput<T> { start = end; } - match self.selection_direction { - SelectionDirection::None | - SelectionDirection::Forward => { - self.selection_begin = Some(self.get_text_point_for_absolute_point(start)); - self.edit_point = self.get_text_point_for_absolute_point(end); + self.selection_direction = direction; + + match direction { + SelectionDirection::None | SelectionDirection::Forward => { + self.selection_origin = Some(self.offset_to_text_point(start)); + self.edit_point = self.offset_to_text_point(end); }, SelectionDirection::Backward => { - self.selection_begin = Some(self.get_text_point_for_absolute_point(end)); - self.edit_point = self.get_text_point_for_absolute_point(start); - } + self.selection_origin = Some(self.offset_to_text_point(end)); + self.edit_point = self.offset_to_text_point(start); + }, } self.assert_ok_selection(); } - pub fn get_selection_start(&self) -> u32 { - let selection_start = match self.selection_begin { - Some(selection_begin_point) => { - self.get_absolute_point_for_text_point(&selection_begin_point) - }, - None => self.get_absolute_insertion_point() - }; - - selection_start as u32 - } - + /// Set the edit point index position based off of a given grapheme cluster offset pub fn set_edit_point_index(&mut self, index: usize) { - let byte_size = self.lines[self.edit_point.line] + let byte_offset = self.lines[self.edit_point.line] .graphemes(true) .take(index) - .fold(0, |acc, x| acc + x.len()); - self.edit_point.index = byte_size; + .fold(UTF8Bytes::zero(), |acc, x| acc + x.len_utf8()); + self.edit_point.index = byte_offset; } } |