Better text selection
This commit is contained in:
@@ -69,6 +69,10 @@ pub enum PlatformEvent {
|
||||
window_id: WindowId,
|
||||
event: KeyboardEvent,
|
||||
},
|
||||
ClipboardText {
|
||||
window_id: WindowId,
|
||||
text: String,
|
||||
},
|
||||
PrimarySelectionText {
|
||||
window_id: WindowId,
|
||||
text: String,
|
||||
@@ -107,6 +111,13 @@ pub enum PlatformRequest {
|
||||
window_id: WindowId,
|
||||
scene: SceneSnapshot,
|
||||
},
|
||||
SetClipboardText {
|
||||
window_id: WindowId,
|
||||
text: String,
|
||||
},
|
||||
RequestClipboardText {
|
||||
window_id: WindowId,
|
||||
},
|
||||
SetPrimarySelectionText {
|
||||
window_id: WindowId,
|
||||
text: String,
|
||||
@@ -254,6 +265,21 @@ impl PlatformProxy {
|
||||
self.send(PlatformRequest::RequestPrimarySelectionText { window_id })
|
||||
}
|
||||
|
||||
pub fn set_clipboard_text(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
text: impl Into<String>,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformRequest::SetClipboardText {
|
||||
window_id,
|
||||
text: text.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn request_clipboard_text(&self, window_id: WindowId) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformRequest::RequestClipboardText { window_id })
|
||||
}
|
||||
|
||||
pub fn set_cursor_icon(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
@@ -328,6 +354,8 @@ pub fn start_headless() -> PlatformRuntime {
|
||||
PlatformRequest::ReplaceScene { window_id, scene } => {
|
||||
handle_replace_scene(&state, window_id, scene);
|
||||
}
|
||||
PlatformRequest::SetClipboardText { .. } => {}
|
||||
PlatformRequest::RequestClipboardText { .. } => {}
|
||||
PlatformRequest::SetPrimarySelectionText { .. } => {}
|
||||
PlatformRequest::RequestPrimarySelectionText { .. } => {}
|
||||
PlatformRequest::SetCursorIcon { .. } => {}
|
||||
|
||||
@@ -117,6 +117,16 @@ impl WindowController {
|
||||
self.proxy.set_primary_selection_text(self.id, text)
|
||||
}
|
||||
|
||||
/// Copies plain text to the platform clipboard for this window.
|
||||
pub fn set_clipboard_text(&self, text: impl Into<String>) -> Result<(), PlatformClosed> {
|
||||
self.proxy.set_clipboard_text(self.id, text)
|
||||
}
|
||||
|
||||
/// Requests the current plain-text clipboard contents from the platform.
|
||||
pub fn request_clipboard_text(&self) -> Result<(), PlatformClosed> {
|
||||
self.proxy.request_clipboard_text(self.id)
|
||||
}
|
||||
|
||||
/// Requests the current plain-text primary selection contents from the platform.
|
||||
pub fn request_primary_selection_text(&self) -> Result<(), PlatformClosed> {
|
||||
self.proxy.request_primary_selection_text(self.id)
|
||||
|
||||
@@ -240,6 +240,77 @@ impl PreparedText {
|
||||
))
|
||||
}
|
||||
|
||||
pub fn previous_char_boundary(&self, offset: usize) -> usize {
|
||||
let offset = offset.min(self.text.len());
|
||||
self.text[..offset]
|
||||
.char_indices()
|
||||
.last()
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn next_char_boundary(&self, offset: usize) -> usize {
|
||||
let offset = offset.min(self.text.len());
|
||||
if offset >= self.text.len() {
|
||||
return self.text.len();
|
||||
}
|
||||
self.text
|
||||
.char_indices()
|
||||
.find_map(|(index, _)| (index > offset).then_some(index))
|
||||
.unwrap_or(self.text.len())
|
||||
}
|
||||
|
||||
pub fn line_start_offset(&self, offset: usize) -> Option<usize> {
|
||||
Some(self.line_for_offset(offset)?.text_start)
|
||||
}
|
||||
|
||||
pub fn line_end_offset(&self, offset: usize) -> Option<usize> {
|
||||
Some(self.line_for_offset(offset)?.text_end)
|
||||
}
|
||||
|
||||
pub fn vertical_offset(&self, offset: usize, line_delta: isize) -> Option<usize> {
|
||||
if line_delta == 0 {
|
||||
return Some(offset.min(self.text.len()));
|
||||
}
|
||||
let offset = offset.min(self.text.len());
|
||||
let line = self.line_for_offset(offset)?;
|
||||
let current_index = self.lines.iter().position(|candidate| candidate == line)?;
|
||||
let next_index = current_index.checked_add_signed(line_delta)?;
|
||||
let next_line = self.lines.get(next_index)?;
|
||||
let target_x = self.caret_x_for_line_offset(line, offset);
|
||||
Some(self.byte_offset_for_line_position(next_line, target_x))
|
||||
}
|
||||
|
||||
pub fn word_range_for_offset(&self, offset: usize) -> Range<usize> {
|
||||
if self.text.is_empty() {
|
||||
return 0..0;
|
||||
}
|
||||
let offset = offset.min(self.text.len());
|
||||
let target = self.word_class_offset(offset);
|
||||
let Some((target_start, target_ch)) = self.char_at(target) else {
|
||||
return 0..self.text.len();
|
||||
};
|
||||
let target_class = classify_word_char(target_ch);
|
||||
|
||||
let mut start = target_start;
|
||||
while let Some((previous_start, previous_ch)) = self.char_before(start) {
|
||||
if classify_word_char(previous_ch) != target_class {
|
||||
break;
|
||||
}
|
||||
start = previous_start;
|
||||
}
|
||||
|
||||
let mut end = target_start + target_ch.len_utf8();
|
||||
while let Some((next_start, next_ch)) = self.char_at(end) {
|
||||
if classify_word_char(next_ch) != target_class {
|
||||
break;
|
||||
}
|
||||
end = next_start + next_ch.len_utf8();
|
||||
}
|
||||
|
||||
start..end
|
||||
}
|
||||
|
||||
pub fn apply_selected_text_color(&mut self, start: usize, end: usize) {
|
||||
let Some(selected_color) = self.selection_style.text_color else {
|
||||
return;
|
||||
@@ -336,6 +407,50 @@ impl PreparedText {
|
||||
.map(|glyph| glyph.position.x + glyph.advance.max(0.0))
|
||||
.unwrap_or(line.rect.origin.x)
|
||||
}
|
||||
|
||||
fn char_at(&self, offset: usize) -> Option<(usize, char)> {
|
||||
if offset >= self.text.len() {
|
||||
return None;
|
||||
}
|
||||
self.text[offset..].chars().next().map(|ch| (offset, ch))
|
||||
}
|
||||
|
||||
fn char_before(&self, offset: usize) -> Option<(usize, char)> {
|
||||
let offset = offset.min(self.text.len());
|
||||
self.text[..offset].char_indices().last()
|
||||
}
|
||||
|
||||
fn word_class_offset(&self, offset: usize) -> usize {
|
||||
if offset >= self.text.len() {
|
||||
return self.previous_char_boundary(offset);
|
||||
}
|
||||
if offset > 0
|
||||
&& let (Some((_, previous)), Some((_, current))) =
|
||||
(self.char_before(offset), self.char_at(offset))
|
||||
&& classify_word_char(current) == WordClass::Whitespace
|
||||
&& classify_word_char(previous) == WordClass::Word
|
||||
{
|
||||
return self.previous_char_boundary(offset);
|
||||
}
|
||||
offset
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum WordClass {
|
||||
Word,
|
||||
Whitespace,
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
fn classify_word_char(ch: char) -> WordClass {
|
||||
if ch.is_whitespace() {
|
||||
WordClass::Whitespace
|
||||
} else if ch.is_alphanumeric() || ch == '_' {
|
||||
WordClass::Word
|
||||
} else {
|
||||
WordClass::Punctuation
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -484,4 +599,58 @@ mod tests {
|
||||
Some(Rect::new(42.0, 20.0, 2.0, 16.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_text_word_range_tracks_words_and_punctuation() {
|
||||
let text = PreparedText::monospace(
|
||||
"alpha beta, gamma",
|
||||
Point::new(10.0, 20.0),
|
||||
16.0,
|
||||
8.0,
|
||||
Color::rgb(0xFF, 0xFF, 0xFF),
|
||||
);
|
||||
|
||||
assert_eq!(text.word_range_for_offset(1), 0..5);
|
||||
assert_eq!(text.word_range_for_offset(6), 6..10);
|
||||
assert_eq!(text.word_range_for_offset(10), 10..11);
|
||||
assert_eq!(text.word_range_for_offset(text.text.len()), 12..17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_text_vertical_offset_moves_between_lines() {
|
||||
let mut text = PreparedText::monospace(
|
||||
"abcdwxyz",
|
||||
Point::new(10.0, 20.0),
|
||||
16.0,
|
||||
8.0,
|
||||
Color::rgb(0xFF, 0xFF, 0xFF),
|
||||
);
|
||||
text.lines = vec![
|
||||
super::PreparedTextLine {
|
||||
rect: Rect::new(10.0, 20.0, 32.0, 16.0),
|
||||
text_start: 0,
|
||||
text_end: 4,
|
||||
glyph_start: 0,
|
||||
glyph_end: 4,
|
||||
},
|
||||
super::PreparedTextLine {
|
||||
rect: Rect::new(10.0, 36.0, 32.0, 16.0),
|
||||
text_start: 4,
|
||||
text_end: 8,
|
||||
glyph_start: 4,
|
||||
glyph_end: 8,
|
||||
},
|
||||
];
|
||||
for (index, glyph) in text.glyphs.iter_mut().enumerate() {
|
||||
if index >= 4 {
|
||||
glyph.position.y = 36.0;
|
||||
glyph.position.x = 10.0 + ((index - 4) as f32 * 8.0);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(text.vertical_offset(2, 1), Some(6));
|
||||
assert_eq!(text.vertical_offset(6, -1), Some(2));
|
||||
assert_eq!(text.line_start_offset(6), Some(4));
|
||||
assert_eq!(text.line_end_offset(2), Some(4));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user