From d84b8c50f55a28282f1e69ef51c651e70d83f9c3 Mon Sep 17 00:00:00 2001 From: Valentin Kahl Date: Fri, 3 Jul 2020 02:35:11 +0200 Subject: [PATCH 1/2] Added ctrl/shift hotkey support to textbox. New key commands: Ctrl + Arrow keys -> jump words Ctrl + Shift + Arrow keys -> select words Ctrl + Backspace/Delete -> delete words Shift + Home/End -> select to home/end Consecutive non-alphanumeric characters are skipped when jumping words. The behaviour matches the firefox url bar. --- CHANGELOG.md | 3 ++ druid/src/text/editable_text.rs | 74 +++++++++++++++++++++++++++++++-- druid/src/text/movement.rs | 31 +++++++++----- druid/src/text/text_input.rs | 54 ++++++++++++++++++------ druid/src/widget/textbox.rs | 8 ++++ 5 files changed, 144 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e769df73d2..85cedb40f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ You can find its changes [documented below](#060---2020-06-01). ### Added +- Added ctrl/shift key support to textbox. ([#1063] by [@vkahl]) + ### Changed - `Image` and `ImageData` exported by default. ([#1011] by [@covercash2]) @@ -240,6 +242,7 @@ Last release without a changelog :( [@raphlinus]: https://github.com/raphlinus [@binomial0]: https://github.com/binomial0 [@chris-zen]: https://github.com/chris-zen +[@vkahl]: https://github.com/vkahl [#599]: https://github.com/linebender/druid/pull/599 [#611]: https://github.com/linebender/druid/pull/611 diff --git a/druid/src/text/editable_text.rs b/druid/src/text/editable_text.rs index 2e93bc4da9..524e8fdc16 100644 --- a/druid/src/text/editable_text.rs +++ b/druid/src/text/editable_text.rs @@ -17,7 +17,7 @@ use std::borrow::Cow; use std::ops::Range; -use unicode_segmentation::GraphemeCursor; +use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; /// An EditableText trait. pub trait EditableText: Sized { @@ -40,16 +40,22 @@ pub trait EditableText: Sized { /// Get length of text (in bytes). fn len(&self) -> usize; + /// Get the previous word offset from the given offset, if it exists. + fn prev_word_offset(&self, offset: usize) -> Option; + + /// Get the next word offset from the given offset, if it exists. + fn next_word_offset(&self, offset: usize) -> Option; + /// Get the next grapheme offset from the given offset, if it exists. fn prev_grapheme_offset(&self, offset: usize) -> Option; - /// Get the previous grapheme offset from the given offset, if it exists. + /// Get the next grapheme offset from the given offset, if it exists. fn next_grapheme_offset(&self, offset: usize) -> Option; - /// Get the next codepoint offset from the given offset, if it exists. + /// Get the previous codepoint offset from the given offset, if it exists. fn prev_codepoint_offset(&self, offset: usize) -> Option; - /// Get the previous codepoint offset from the given offset, if it exists. + /// Get the next codepoint offset from the given offset, if it exists. fn next_codepoint_offset(&self, offset: usize) -> Option; fn is_empty(&self) -> bool; @@ -111,6 +117,38 @@ impl EditableText for String { } } + fn prev_word_offset(&self, from: usize) -> Option { + let mut graphemes = self.get(0..from)?.graphemes(true); + let mut offset = from; + let mut passed_alphanumeric = false; + while let Some(prev_grapheme) = graphemes.next_back() { + let is_alphanumeric = prev_grapheme.chars().next()?.is_alphanumeric(); + if is_alphanumeric { + passed_alphanumeric = true; + } else if passed_alphanumeric { + return Some(offset); + } + offset -= prev_grapheme.len(); + } + None + } + + fn next_word_offset(&self, from: usize) -> Option { + let mut graphemes = self.get(from..)?.graphemes(true); + let mut offset = from; + let mut passed_alphanumeric = false; + while let Some(next_grapheme) = graphemes.next() { + let is_alphanumeric = next_grapheme.chars().next()?.is_alphanumeric(); + if is_alphanumeric { + passed_alphanumeric = true; + } else if passed_alphanumeric { + return Some(offset); + } + offset += next_grapheme.len(); + } + Some(self.len()) + } + fn is_empty(&self) -> bool { self.is_empty() } @@ -344,4 +382,32 @@ mod tests { assert_eq!(Some(17), a.next_grapheme_offset(9)); assert_eq!(None, a.next_grapheme_offset(17)); } + + #[test] + fn prev_word_offset() { + let a = String::from("Technically a word: ৬藏A\u{030a}\u{110b}\u{1161}"); + assert_eq!(Some(20), a.prev_word_offset(35)); + assert_eq!(Some(20), a.prev_word_offset(27)); + assert_eq!(Some(20), a.prev_word_offset(23)); + assert_eq!(Some(14), a.prev_word_offset(20)); + assert_eq!(Some(14), a.prev_word_offset(19)); + assert_eq!(Some(12), a.prev_word_offset(13)); + assert_eq!(None, a.prev_word_offset(12)); + assert_eq!(None, a.prev_word_offset(11)); + assert_eq!(None, a.prev_word_offset(0)); + } + + #[test] + fn next_word_offset() { + let a = String::from("Technically a word: ৬藏A\u{030a}\u{110b}\u{1161}"); + assert_eq!(Some(11), a.next_word_offset(0)); + assert_eq!(Some(11), a.next_word_offset(7)); + assert_eq!(Some(13), a.next_word_offset(11)); + assert_eq!(Some(18), a.next_word_offset(14)); + assert_eq!(Some(35), a.next_word_offset(18)); + assert_eq!(Some(35), a.next_word_offset(19)); + assert_eq!(Some(35), a.next_word_offset(20)); + assert_eq!(Some(35), a.next_word_offset(26)); + assert_eq!(Some(35), a.next_word_offset(35)); + } } diff --git a/druid/src/text/movement.rs b/druid/src/text/movement.rs index 94e458beb7..c16496d193 100644 --- a/druid/src/text/movement.rs +++ b/druid/src/text/movement.rs @@ -23,6 +23,10 @@ pub enum Movement { Left, /// Move to the right by one grapheme cluster. Right, + /// Move to the left by one word. + LeftWord, + /// Move to the right by one word. + RightWord, /// Move to left end of visible line. LeftOfLine, /// Move to right end of visible line. @@ -34,22 +38,14 @@ pub fn movement(m: Movement, s: Selection, text: &impl EditableText, modify: boo let offset = match m { Movement::Left => { if s.is_caret() || modify { - if let Some(offset) = text.prev_grapheme_offset(s.end) { - offset - } else { - 0 - } + text.prev_grapheme_offset(s.end).unwrap_or(0) } else { s.min() } } Movement::Right => { if s.is_caret() || modify { - if let Some(offset) = text.next_grapheme_offset(s.end) { - offset - } else { - s.end - } + text.next_grapheme_offset(s.end).unwrap_or(s.end) } else { s.max() } @@ -57,6 +53,21 @@ pub fn movement(m: Movement, s: Selection, text: &impl EditableText, modify: boo Movement::LeftOfLine => 0, Movement::RightOfLine => text.len(), + + Movement::LeftWord => { + if s.is_caret() || modify { + text.prev_word_offset(s.end).unwrap_or(0) + } else { + s.min() + } + } + Movement::RightWord => { + if s.is_caret() || modify { + text.next_word_offset(s.end).unwrap_or(s.end) + } else { + s.max() + } + } }; Selection::new(if modify { s.start } else { offset }, offset) } diff --git a/druid/src/text/text_input.rs b/druid/src/text/text_input.rs index c1025e338f..eb2d1fdabf 100644 --- a/druid/src/text/text_input.rs +++ b/druid/src/text/text_input.rs @@ -32,6 +32,8 @@ pub enum EditAction { Drag(MouseAction), Delete, Backspace, + JumpDelete(Movement), + JumpBackspace(Movement), Insert(String), Paste(String), } @@ -64,19 +66,21 @@ impl BasicTextInput { impl TextInput for BasicTextInput { fn handle_event(&self, event: &KeyEvent) -> Option { let action = match event { - // Select all (Ctrl+A || Cmd+A) - k_e if (HotKey::new(SysMods::Cmd, "a")).matches(k_e) => EditAction::SelectAll, - // Jump left (Ctrl+ArrowLeft || Cmd+ArrowLeft) - k_e if (HotKey::new(SysMods::Cmd, KbKey::ArrowLeft)).matches(k_e) - || HotKey::new(None, KbKey::Home).matches(k_e) => - { - EditAction::Move(Movement::LeftOfLine) + // Select left word (Shift+Ctrl+ArrowLeft || Shift+Cmd+ArrowLeft) + k_e if (HotKey::new(SysMods::CmdShift, KbKey::ArrowLeft)).matches(k_e) => { + EditAction::ModifySelection(Movement::LeftWord) } - // Jump right (Ctrl+ArrowRight || Cmd+ArrowRight) - k_e if (HotKey::new(SysMods::Cmd, KbKey::ArrowRight)).matches(k_e) - || HotKey::new(None, KbKey::End).matches(k_e) => - { - EditAction::Move(Movement::RightOfLine) + // Select right word (Shift+Ctrl+ArrowRight || Shift+Cmd+ArrowRight) + k_e if (HotKey::new(SysMods::CmdShift, KbKey::ArrowRight)).matches(k_e) => { + EditAction::ModifySelection(Movement::RightWord) + } + // Select to home (Shift+Home) + k_e if (HotKey::new(SysMods::Shift, KbKey::Home)).matches(k_e) => { + EditAction::ModifySelection(Movement::LeftOfLine) + } + // Select to end (Shift+End) + k_e if (HotKey::new(SysMods::Shift, KbKey::End)).matches(k_e) => { + EditAction::ModifySelection(Movement::RightOfLine) } // Select left (Shift+ArrowLeft) k_e if (HotKey::new(SysMods::Shift, KbKey::ArrowLeft)).matches(k_e) => { @@ -86,6 +90,16 @@ impl TextInput for BasicTextInput { k_e if (HotKey::new(SysMods::Shift, KbKey::ArrowRight)).matches(k_e) => { EditAction::ModifySelection(Movement::Right) } + // Select all (Ctrl+A || Cmd+A) + k_e if (HotKey::new(SysMods::Cmd, "a")).matches(k_e) => EditAction::SelectAll, + // Left word (Ctrl+ArrowLeft || Cmd+ArrowLeft) + k_e if (HotKey::new(SysMods::Cmd, KbKey::ArrowLeft)).matches(k_e) => { + EditAction::Move(Movement::LeftWord) + } + // Right word (Ctrl+ArrowRight || Cmd+ArrowRight) + k_e if (HotKey::new(SysMods::Cmd, KbKey::ArrowRight)).matches(k_e) => { + EditAction::Move(Movement::RightWord) + } // Move left (ArrowLeft) k_e if (HotKey::new(None, KbKey::ArrowLeft)).matches(k_e) => { EditAction::Move(Movement::Left) @@ -94,10 +108,26 @@ impl TextInput for BasicTextInput { k_e if (HotKey::new(None, KbKey::ArrowRight)).matches(k_e) => { EditAction::Move(Movement::Right) } + // Delete left word + k_e if (HotKey::new(SysMods::Cmd, KbKey::Backspace)).matches(k_e) => { + EditAction::JumpBackspace(Movement::LeftWord) + } + // Delete right word + k_e if (HotKey::new(SysMods::Cmd, KbKey::Delete)).matches(k_e) => { + EditAction::JumpDelete(Movement::RightWord) + } // Backspace k_e if (HotKey::new(None, KbKey::Backspace)).matches(k_e) => EditAction::Backspace, // Delete k_e if (HotKey::new(None, KbKey::Delete)).matches(k_e) => EditAction::Delete, + // Home + k_e if (HotKey::new(None, KbKey::Home)).matches(k_e) => { + EditAction::Move(Movement::LeftOfLine) + } + // End + k_e if (HotKey::new(None, KbKey::End)).matches(k_e) => { + EditAction::Move(Movement::RightOfLine) + } // Actual typing k_e if key_event_is_printable(k_e) => { if let KbKey::Character(chars) = &k_e.key { diff --git a/druid/src/widget/textbox.rs b/druid/src/widget/textbox.rs index 9698ad0663..10fa00a69e 100644 --- a/druid/src/widget/textbox.rs +++ b/druid/src/widget/textbox.rs @@ -130,6 +130,14 @@ impl TextBox { EditAction::Insert(chars) | EditAction::Paste(chars) => self.insert(text, &chars), EditAction::Backspace => self.delete_backward(text), EditAction::Delete => self.delete_forward(text), + EditAction::JumpDelete(movement) => { + self.move_selection(movement, text, true); + self.delete_forward(text) + } + EditAction::JumpBackspace(movement) => { + self.move_selection(movement, text, true); + self.delete_backward(text) + } EditAction::Move(movement) => self.move_selection(movement, text, false), EditAction::ModifySelection(movement) => self.move_selection(movement, text, true), EditAction::SelectAll => self.selection.all(text), From 20b03c9b5cb4385073be1b0782151f368f52b72e Mon Sep 17 00:00:00 2001 From: Valentin Kahl Date: Fri, 3 Jul 2020 03:07:36 +0200 Subject: [PATCH 2/2] Fix clippy warning: use for-loops instead of while let --- druid/src/text/editable_text.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/druid/src/text/editable_text.rs b/druid/src/text/editable_text.rs index 524e8fdc16..52c4087281 100644 --- a/druid/src/text/editable_text.rs +++ b/druid/src/text/editable_text.rs @@ -118,10 +118,9 @@ impl EditableText for String { } fn prev_word_offset(&self, from: usize) -> Option { - let mut graphemes = self.get(0..from)?.graphemes(true); let mut offset = from; let mut passed_alphanumeric = false; - while let Some(prev_grapheme) = graphemes.next_back() { + for prev_grapheme in self.get(0..from)?.graphemes(true).rev() { let is_alphanumeric = prev_grapheme.chars().next()?.is_alphanumeric(); if is_alphanumeric { passed_alphanumeric = true; @@ -134,10 +133,9 @@ impl EditableText for String { } fn next_word_offset(&self, from: usize) -> Option { - let mut graphemes = self.get(from..)?.graphemes(true); let mut offset = from; let mut passed_alphanumeric = false; - while let Some(next_grapheme) = graphemes.next() { + for next_grapheme in self.get(from..)?.graphemes(true) { let is_alphanumeric = next_grapheme.chars().next()?.is_alphanumeric(); if is_alphanumeric { passed_alphanumeric = true;