Skip to content

Commit

Permalink
Added ctrl/shift hotkey support to textbox.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Valentin Kahl committed Jul 3, 2020
1 parent 6027317 commit d84b8c5
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 26 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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
Expand Down
74 changes: 70 additions & 4 deletions druid/src/text/editable_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<usize>;

/// Get the next word offset from the given offset, if it exists.
fn next_word_offset(&self, offset: usize) -> Option<usize>;

/// Get the next grapheme offset from the given offset, if it exists.
fn prev_grapheme_offset(&self, offset: usize) -> Option<usize>;

/// 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<usize>;

/// 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<usize>;

/// 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<usize>;

fn is_empty(&self) -> bool;
Expand Down Expand Up @@ -111,6 +117,38 @@ impl EditableText for String {
}
}

fn prev_word_offset(&self, from: usize) -> Option<usize> {
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<usize> {
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()
}
Expand Down Expand Up @@ -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));
}
}
31 changes: 21 additions & 10 deletions druid/src/text/movement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -34,29 +38,36 @@ 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()
}
}

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)
}
54 changes: 42 additions & 12 deletions druid/src/text/text_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ pub enum EditAction {
Drag(MouseAction),
Delete,
Backspace,
JumpDelete(Movement),
JumpBackspace(Movement),
Insert(String),
Paste(String),
}
Expand Down Expand Up @@ -64,19 +66,21 @@ impl BasicTextInput {
impl TextInput for BasicTextInput {
fn handle_event(&self, event: &KeyEvent) -> Option<EditAction> {
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) => {
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions druid/src/widget/textbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down

0 comments on commit d84b8c5

Please sign in to comment.