Better text selection

This commit is contained in:
2026-03-21 01:55:06 -04:00
parent 84077b718f
commit 6954c8c74d
7 changed files with 844 additions and 159 deletions

View File

@@ -99,6 +99,14 @@ fn log_platform_event(event: &PlatformEvent) {
"internal wake event received"
);
}
PlatformEvent::ClipboardText { window_id, text } => {
tracing::debug!(
event = "clipboard_text",
window_id = window_id.raw(),
text,
"clipboard text received"
);
}
PlatformEvent::PrimarySelectionText { window_id, text } => {
tracing::debug!(
event = "primary_selection_text",

View File

@@ -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 { .. } => {}

View File

@@ -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)

View File

@@ -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));
}
}