Skip to content

Commit 39f4855

Browse files
committed
Fix word selection logic
This is particularly relevant for when the user double-clicks a word. Previously if the click fell on a word boundary we would not recognize that; now if the click falls on a word boundary we will treat that as the start of the new selection range. In addition, word select now selects *anything* between two word boundaries; we do not care if it is actually a word. As an example: if the user double clicks in whitespcae, we will select any contiguous whitespace. progress on #1652
1 parent dde1587 commit 39f4855

File tree

2 files changed

+76
-20
lines changed

2 files changed

+76
-20
lines changed

druid/src/text/input_component.rs

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use crate::kurbo::{Line, Point, Rect, Vec2};
2525
use crate::piet::TextLayout as _;
2626
use crate::shell::text::{Action as ImeAction, Event as ImeUpdate, InputHandler};
2727
use crate::widget::prelude::*;
28-
use crate::{theme, Cursor, Env, Modifiers, Selector, TextAlignment, UpdateCtx};
28+
use crate::{text, theme, Cursor, Env, Modifiers, Selector, TextAlignment, UpdateCtx};
2929

3030
/// A widget that accepts text input.
3131
///
@@ -564,28 +564,45 @@ impl<T: TextStorage + EditableText> EditSession<T> {
564564
fn do_action(&mut self, buffer: &mut T, action: ImeAction) {
565565
match action {
566566
ImeAction::Move(movement) => {
567-
let sel = crate::text::movement(movement, self.selection, &self.layout, false);
567+
let sel = text::movement(movement, self.selection, &self.layout, false);
568568
self.external_selection_change = Some(sel);
569569
self.scroll_to_selection_end(false);
570570
}
571571
ImeAction::MoveSelecting(movement) => {
572-
let sel = crate::text::movement(movement, self.selection, &self.layout, true);
572+
let sel = text::movement(movement, self.selection, &self.layout, true);
573573
self.external_selection_change = Some(sel);
574574
self.scroll_to_selection_end(false);
575575
}
576576
ImeAction::SelectAll => {
577-
let len = self.layout.text().as_ref().map(|t| t.len()).unwrap_or(0);
577+
let len = buffer.len();
578578
self.external_selection_change = Some(Selection::new(0, len));
579579
}
580-
//ImeAction::SelectLine | ImeAction::SelectParagraph | ImeAction::SelectWord => {
581-
//tracing::warn!("Line/Word selection actions are not implemented");
582-
//}
580+
ImeAction::SelectWord => {
581+
if self.selection.is_caret() {
582+
let range =
583+
text::movement::word_range_for_pos(buffer.as_str(), self.selection.active);
584+
self.external_selection_change = Some(Selection::new(range.start, range.end));
585+
}
586+
587+
// it is unclear what the behaviour should be if the selection
588+
// is not a caret (and may span multiple words)
589+
}
590+
// This requires us to have access to the layout, which might be stale?
591+
ImeAction::SelectLine => (),
592+
// this assumes our internal selection is consistent with the buffer?
593+
ImeAction::SelectParagraph => {
594+
if !self.selection.is_caret() || buffer.len() < self.selection.active {
595+
return;
596+
}
597+
let prev = buffer.preceding_line_break(self.selection.active);
598+
let next = buffer.next_line_break(self.selection.active);
599+
self.external_selection_change = Some(Selection::new(prev, next));
600+
}
583601
ImeAction::Delete(movement) if self.selection.is_caret() => {
584602
if movement == Movement::Grapheme(druid_shell::text::Direction::Upstream) {
585603
self.backspace(buffer);
586604
} else {
587-
let to_delete =
588-
crate::text::movement(movement, self.selection, &self.layout, true);
605+
let to_delete = text::movement(movement, self.selection, &self.layout, true);
589606
self.selection = to_delete;
590607
self.ime_insert_text(buffer, "")
591608
}
@@ -643,7 +660,7 @@ impl<T: TextStorage + EditableText> EditSession<T> {
643660

644661
fn backspace(&mut self, buffer: &mut T) {
645662
let to_del = if self.selection.is_caret() {
646-
let del_start = crate::text::offset_for_delete_backwards(&self.selection, buffer);
663+
let del_start = text::offset_for_delete_backwards(&self.selection, buffer);
647664
del_start..self.selection.anchor
648665
} else {
649666
self.selection.range()
@@ -680,26 +697,37 @@ impl<T: TextStorage + EditableText> EditSession<T> {
680697
}
681698

682699
fn sel_region_for_pos(&mut self, pos: usize, click_count: u8) -> Range<usize> {
683-
let text = match self.layout.text() {
684-
Some(text) => text,
685-
None => return pos..pos,
686-
};
687700
match click_count {
688701
1 => pos..pos,
689-
2 => {
690-
//FIXME: this doesn't handle whitespace correctly
691-
let word_min = text.prev_word_offset(pos).unwrap_or(0);
692-
let word_max = text.next_word_offset(pos).unwrap_or_else(|| text.len());
693-
word_min..word_max
694-
}
702+
2 => self.word_for_pos(pos),
695703
_ => {
704+
let text = match self.layout.text() {
705+
Some(text) => text,
706+
None => return pos..pos,
707+
};
696708
let line_min = text.preceding_line_break(pos);
697709
let line_max = text.next_line_break(pos);
698710
line_min..line_max
699711
}
700712
}
701713
}
702714

715+
fn word_for_pos(&self, pos: usize) -> Range<usize> {
716+
let layout = match self.layout.layout() {
717+
Some(layout) => layout,
718+
None => return pos..pos,
719+
};
720+
721+
let line_n = layout.hit_test_text_position(pos).line;
722+
let lm = layout.line_metric(line_n).unwrap();
723+
let text = layout.line_text(line_n).unwrap();
724+
let rel_pos = pos - lm.start_offset;
725+
let mut range = text::movement::word_range_for_pos(text, rel_pos);
726+
range.start += lm.start_offset;
727+
range.end += lm.start_offset;
728+
range
729+
}
730+
703731
fn update(&mut self, ctx: &mut UpdateCtx, new_data: &T, env: &Env) {
704732
if self
705733
.layout

druid/src/text/movement.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414

1515
//! Text editing movements.
1616
17+
use std::ops::Range;
18+
19+
use unicode_segmentation::UnicodeSegmentation;
20+
1721
use crate::kurbo::Point;
1822
use crate::piet::TextLayout as _;
1923
pub use crate::shell::text::{Direction, Movement, VerticalMovement, WritingDirection};
@@ -137,3 +141,27 @@ pub fn movement<T: EditableText + TextStorage>(
137141
let start = if modify { s.anchor } else { offset };
138142
Selection::new(start, offset).with_h_pos(h_pos)
139143
}
144+
145+
/// Given a position in some text, return the containing word boundaries.
146+
///
147+
/// The returned range may not necessary be a 'word'; for instance it could be
148+
/// the sequence of whitespace between two words.
149+
///
150+
/// If the position is on a word boundary, that will be considered the start
151+
/// of the range.
152+
///
153+
/// This uses Unicode word boundaries, as defined in [UAX#29].
154+
///
155+
/// [UAX#29]: http://www.unicode.org/reports/tr29/
156+
pub fn word_range_for_pos(text: &str, pos: usize) -> Range<usize> {
157+
let mut word_iter = text.split_word_bound_indices().peekable();
158+
let mut word_start = pos;
159+
while let Some((ix, _)) = word_iter.next() {
160+
if word_iter.peek().map(|(ix, _)| *ix > pos).unwrap_or(false) {
161+
word_start = ix;
162+
break;
163+
}
164+
}
165+
let word_end = word_iter.next().map(|(ix, _)| ix).unwrap_or(pos);
166+
word_start..word_end
167+
}

0 commit comments

Comments
 (0)