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

@@ -6,9 +6,9 @@ use ruin_runtime::{TimeoutHandle, clear_timeout, set_timeout};
use ruin_ui::{
Color, CursorIcon, DisplayItem, Edges, Element, ElementId, InteractionTree, KeyboardEvent,
KeyboardEventKind, KeyboardKey, LayoutSnapshot, PlatformEvent, PointerButton, PointerEvent,
PointerEventKind, PointerRouter, Quad, RoutedPointerEventKind, SceneSnapshot, TextAlign,
TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight, TextStyle,
TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate,
PointerEventKind, PointerRouter, PreparedText, Quad, RoutedPointerEventKind, SceneSnapshot,
TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight,
TextStyle, TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate,
layout_snapshot_with_text_system,
};
use ruin_ui_platform_wayland::start_wayland_ui;
@@ -32,6 +32,8 @@ const INPUT_TEXT_ID: ElementId = ElementId::new(105);
const SECOND_INPUT_FIELD_ID: ElementId = ElementId::new(106);
const SECOND_INPUT_TEXT_ID: ElementId = ElementId::new(107);
const INPUT_CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
const MULTI_CLICK_INTERVAL: Duration = Duration::from_millis(350);
const MULTI_CLICK_DISTANCE_SQUARED: f32 = 36.0;
const DEMO_SELECTION_STYLE: TextSelectionStyle =
TextSelectionStyle::new(Color::rgba(0x6C, 0x8E, 0xFF, 0xB8))
.with_text_color(Color::rgb(0x0D, 0x14, 0x25));
@@ -55,6 +57,14 @@ struct SelectionDrag {
anchor: usize,
}
#[derive(Clone, Copy, Debug)]
struct ClickTracker {
element_id: ElementId,
last_position: ruin_ui::Point,
last_click_at: Instant,
count: u8,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct TextSelection {
element_id: ElementId,
@@ -105,13 +115,15 @@ impl InputFieldState {
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct InputOutcome {
focus_changed: bool,
text_changed: bool,
caret_changed: bool,
selection_changed: bool,
request_primary_paste: bool,
request_clipboard_paste: bool,
copied_text: Option<String>,
}
fn focused_input_index(
@@ -157,6 +169,18 @@ fn selection_bounds(selection: TextSelection) -> (usize, usize) {
}
}
fn selection_navigation_anchor_and_focus(
selection: TextSelection,
key: &KeyboardKey,
) -> (usize, usize) {
let (start, end) = selection_bounds(selection);
match key {
KeyboardKey::ArrowLeft | KeyboardKey::ArrowUp | KeyboardKey::Home => (end, start),
KeyboardKey::ArrowRight | KeyboardKey::ArrowDown | KeyboardKey::End => (start, end),
_ => (selection.anchor, selection.focus),
}
}
fn active_input_selection_bounds(
selection: Option<TextSelection>,
input_field: &InputFieldState,
@@ -168,6 +192,10 @@ fn active_input_selection_bounds(
Some(selection_bounds(selection))
}
fn has_active_input_selection(selection: Option<TextSelection>, input_field: &InputFieldState) -> bool {
active_input_selection_bounds(selection, input_field).is_some()
}
fn clear_input_selection_for(
selection: &mut Option<TextSelection>,
input_field: &InputFieldState,
@@ -221,6 +249,83 @@ fn insert_text_into_focused_input(
}
}
fn selection_text(
selection: Option<TextSelection>,
prepared_text: &PreparedText,
) -> Option<String> {
let selection = selection?;
if prepared_text.element_id != Some(selection.element_id) {
return None;
}
if selection.is_collapsed() {
return None;
}
prepared_text
.selected_text(selection.anchor, selection.focus)
.filter(|text| !text.is_empty())
.map(str::to_owned)
}
fn selection_target_prepared_text(
interaction_tree: &InteractionTree,
selection: Option<TextSelection>,
) -> Option<(TextSelection, &PreparedText)> {
let selection = selection?;
interaction_tree
.text_for_element(selection.element_id)
.map(|prepared_text| (selection, prepared_text))
}
fn navigation_target_offset(
prepared_text: &PreparedText,
offset: usize,
key: &KeyboardKey,
) -> Option<usize> {
match key {
KeyboardKey::ArrowLeft => Some(prepared_text.previous_char_boundary(offset)),
KeyboardKey::ArrowRight => Some(prepared_text.next_char_boundary(offset)),
KeyboardKey::ArrowUp => prepared_text.vertical_offset(offset, -1),
KeyboardKey::ArrowDown => prepared_text.vertical_offset(offset, 1),
KeyboardKey::Home => prepared_text.line_start_offset(offset),
KeyboardKey::End => prepared_text.line_end_offset(offset),
_ => None,
}
}
fn update_click_tracker(
click_tracker: &mut Option<ClickTracker>,
element_id: ElementId,
position: ruin_ui::Point,
) -> u8 {
let now = Instant::now();
let next_count = click_tracker
.as_ref()
.filter(|tracker| {
tracker.element_id == element_id
&& now.duration_since(tracker.last_click_at) <= MULTI_CLICK_INTERVAL
&& {
let dx = tracker.last_position.x - position.x;
let dy = tracker.last_position.y - position.y;
dx * dx + dy * dy <= MULTI_CLICK_DISTANCE_SQUARED
}
})
.map_or(1, |tracker| tracker.count.saturating_add(1).min(3));
*click_tracker = Some(ClickTracker {
element_id,
last_position: position,
last_click_at: now,
count: next_count,
});
next_count
}
fn shortcut_matches(event: &KeyboardEvent, ch: char) -> bool {
matches!(
&event.key,
KeyboardKey::Character(text) if text.len() == 1 && text.eq_ignore_ascii_case(&ch.to_string())
)
}
fn install_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
@@ -283,6 +388,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let mut caret_blink_token = 0_u64;
let mut selection = None;
let mut selection_drag = None;
let mut click_tracker = None::<ClickTracker>;
println!("Opening RUIN paragraph demo window...");
window.set_cursor_icon(current_cursor)?;
@@ -297,6 +403,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let mut pointer_events = Vec::new();
let mut keyboard_events = Vec::new();
let mut pending_blink_token = None::<u64>;
let mut pending_clipboard_text = None::<String>;
let mut pending_primary_selection_text = None::<String>;
let mut close_requested = false;
let mut closed = false;
@@ -326,6 +433,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
PlatformEvent::Wake { window_id, token } if window_id == window.id() => {
pending_blink_token = Some(token);
}
PlatformEvent::ClipboardText { window_id, text } if window_id == window.id() => {
pending_clipboard_text = Some(text);
}
PlatformEvent::PrimarySelectionText { window_id, text }
if window_id == window.id() =>
{
@@ -453,8 +563,10 @@ async fn main() -> Result<(), Box<dyn Error>> {
let mut needs_hover_rebuild = false;
let mut needs_input_rebuild = false;
let mut needs_overlay_present = false;
let mut copied_text = None::<String>;
let mut copied_primary_selection_text = None::<String>;
let mut copied_clipboard_text = None::<String>;
let mut request_primary_paste = false;
let mut request_clipboard_paste = false;
let resize_active =
has_resize_configuration || pending_resize.is_some() || in_flight_resize.is_some();
if !resize_active && let Some(current_interaction_tree) = interaction_tree.as_ref() {
@@ -470,15 +582,17 @@ async fn main() -> Result<(), Box<dyn Error>> {
needs_overlay_present |=
input_outcome.caret_changed || input_outcome.selection_changed;
request_primary_paste |= input_outcome.request_primary_paste;
request_clipboard_paste |= input_outcome.request_clipboard_paste;
let selection_outcome = handle_selection_event(
current_interaction_tree,
event,
&mut selection,
&mut selection_drag,
&mut click_tracker,
);
needs_overlay_present |= selection_outcome.changed;
if selection_outcome.copied_text.is_some() {
copied_text = selection_outcome.copied_text;
copied_primary_selection_text = selection_outcome.copied_text;
}
if selection_drag.is_none() {
@@ -515,15 +629,35 @@ async fn main() -> Result<(), Box<dyn Error>> {
}
}
}
for event in keyboard_events {
let input_outcome = handle_keyboard_input_event(
event,
&mut focused_element,
if let Some(current_interaction_tree) = interaction_tree.as_ref() {
for event in keyboard_events {
let input_outcome = handle_keyboard_input_event(
current_interaction_tree,
event,
&mut focused_element,
&mut input_fields,
&mut selection,
);
needs_input_rebuild |= input_outcome.focus_changed || input_outcome.text_changed;
needs_overlay_present |=
input_outcome.caret_changed || input_outcome.selection_changed;
request_primary_paste |= input_outcome.request_primary_paste;
request_clipboard_paste |= input_outcome.request_clipboard_paste;
if input_outcome.copied_text.is_some() {
copied_clipboard_text = input_outcome.copied_text;
}
}
}
if let Some(text) = pending_clipboard_text {
let input_outcome = insert_text_into_focused_input(
focused_element,
&mut input_fields,
&mut selection,
&text,
);
needs_input_rebuild |= input_outcome.focus_changed || input_outcome.text_changed;
needs_overlay_present |= input_outcome.caret_changed || input_outcome.selection_changed;
needs_input_rebuild |= input_outcome.text_changed;
needs_overlay_present |=
input_outcome.caret_changed || input_outcome.selection_changed;
}
if let Some(text) = pending_primary_selection_text {
let input_outcome = insert_text_into_focused_input(
@@ -554,12 +688,18 @@ async fn main() -> Result<(), Box<dyn Error>> {
needs_overlay_present = true;
}
}
if let Some(copied_text) = copied_text {
if let Some(copied_text) = copied_primary_selection_text {
window.set_primary_selection_text(copied_text)?;
}
if let Some(copied_text) = copied_clipboard_text {
window.set_clipboard_text(copied_text)?;
}
if request_primary_paste {
window.request_primary_selection_text()?;
}
if request_clipboard_paste {
window.request_clipboard_text()?;
}
if (needs_hover_rebuild || needs_input_rebuild) && in_flight_resize.is_none() {
version = version.wrapping_add(1);
tracing::trace!(
@@ -644,6 +784,7 @@ fn handle_selection_event(
event: PointerEvent,
selection: &mut Option<TextSelection>,
selection_drag: &mut Option<SelectionDrag>,
click_tracker: &mut Option<ClickTracker>,
) -> SelectionOutcome {
let mut outcome = SelectionOutcome::default();
match event.kind {
@@ -653,20 +794,47 @@ fn handle_selection_event(
let next_selection = interaction_tree
.text_hit_test(event.position)
.and_then(|hit| {
hit.target.element_id.map(|element_id| TextSelection {
element_id,
anchor: hit.byte_offset,
focus: hit.byte_offset,
let element_id = hit.target.element_id?;
let prepared_text = interaction_tree.text_for_element(element_id)?;
let click_count =
update_click_tracker(click_tracker, element_id, event.position);
Some(match click_count {
2 => {
let range = prepared_text.word_range_for_offset(hit.byte_offset);
TextSelection {
element_id,
anchor: range.start,
focus: range.end,
}
}
3 => TextSelection {
element_id,
anchor: 0,
focus: prepared_text.text.len(),
},
_ => TextSelection {
element_id,
anchor: hit.byte_offset,
focus: hit.byte_offset,
},
})
});
if let Some(next_selection) = next_selection {
*selection_drag = Some(SelectionDrag {
*selection_drag = next_selection.is_collapsed().then_some(SelectionDrag {
element_id: next_selection.element_id,
anchor: next_selection.anchor,
});
outcome.changed = *selection != Some(next_selection);
if !next_selection.is_collapsed() {
outcome.copied_text = interaction_tree
.text_for_element(next_selection.element_id)
.and_then(|prepared_text| {
selection_text(Some(next_selection), prepared_text)
});
}
*selection = Some(next_selection);
} else {
*click_tracker = None;
outcome.changed = selection.take().is_some();
*selection_drag = None;
}
@@ -698,16 +866,9 @@ fn handle_selection_event(
anchor: drag.anchor,
focus: prepared_text.byte_offset_for_position(event.position),
};
if next_selection.is_collapsed() {
outcome.changed = selection.take().is_some();
} else {
outcome.changed = *selection != Some(next_selection);
outcome.copied_text = prepared_text
.selected_text(next_selection.anchor, next_selection.focus)
.filter(|text| !text.is_empty())
.map(str::to_owned);
*selection = Some(next_selection);
}
outcome.changed = *selection != Some(next_selection);
outcome.copied_text = selection_text(Some(next_selection), prepared_text);
*selection = Some(next_selection);
}
PointerEventKind::Down { .. } | PointerEventKind::Up { .. } => {}
PointerEventKind::LeaveWindow => {}
@@ -782,6 +943,7 @@ fn handle_input_focus_event(
}
fn handle_keyboard_input_event(
interaction_tree: &InteractionTree,
event: KeyboardEvent,
focused_element: &mut Option<ElementId>,
input_fields: &mut [InputFieldState],
@@ -817,7 +979,219 @@ fn handle_keyboard_input_event(
return outcome;
}
let Some(input_field) = focused_input_mut(*focused_element, input_fields) else {
if event.modifiers.control {
if shortcut_matches(&event, 'a') {
if let Some(input_field) = focused_input_mut(*focused_element, input_fields) {
let end = input_field.text.len();
let next_selection = TextSelection {
element_id: input_field.text_id,
anchor: 0,
focus: end,
};
outcome.selection_changed = *selection != Some(next_selection);
*selection = Some(next_selection);
if input_field.caret != end {
input_field.caret = end;
outcome.caret_changed = true;
}
} else if let Some((current_selection, prepared_text)) =
selection_target_prepared_text(interaction_tree, *selection)
{
let next_selection = TextSelection {
element_id: current_selection.element_id,
anchor: 0,
focus: prepared_text.text.len(),
};
outcome.selection_changed = *selection != Some(next_selection);
*selection = Some(next_selection);
}
} else if shortcut_matches(&event, 'c') {
if let Some(input_field) = focused_input(*focused_element, input_fields) {
outcome.copied_text = interaction_tree
.text_for_element(input_field.text_id)
.and_then(|prepared_text| {
selection_text(
active_input_selection_bounds(*selection, input_field).map(
|(start, end)| TextSelection {
element_id: input_field.text_id,
anchor: start,
focus: end,
},
),
prepared_text,
)
});
} else if let Some((current_selection, prepared_text)) =
selection_target_prepared_text(interaction_tree, *selection)
{
outcome.copied_text = selection_text(Some(current_selection), prepared_text);
}
} else if shortcut_matches(&event, 'x') {
if let Some(input_index) = focused_input_index(*focused_element, input_fields) {
let text_id = input_fields[input_index].text_id;
outcome.copied_text = interaction_tree
.text_for_element(text_id)
.and_then(|prepared_text| selection_text(*selection, prepared_text));
if let Some(range) =
active_input_selection_bounds(*selection, &input_fields[input_index])
{
let mut replacement =
replace_input_range(&mut input_fields[input_index], selection, range, "");
replacement.copied_text = outcome.copied_text.take();
outcome = replacement;
}
}
} else if shortcut_matches(&event, 'v')
&& focused_input(*focused_element, input_fields).is_some()
{
outcome.request_clipboard_paste = true;
}
} else if let Some(input_index) = focused_input_index(*focused_element, input_fields) {
let text_id = input_fields[input_index].text_id;
let prepared_text = interaction_tree.text_for_element(text_id);
if event.modifiers.shift
&& let Some(prepared_text) = prepared_text
&& let Some(next_caret) =
navigation_target_offset(
prepared_text,
selection
.filter(|current| current.element_id == text_id)
.map(|current| {
selection_navigation_anchor_and_focus(current, &event.key).1
})
.unwrap_or(input_fields[input_index].caret),
&event.key,
)
{
let anchor = selection
.filter(|current| current.element_id == text_id)
.map_or(input_fields[input_index].caret, |current| {
selection_navigation_anchor_and_focus(current, &event.key).0
});
let next_selection = TextSelection {
element_id: text_id,
anchor,
focus: next_caret,
};
outcome.selection_changed = *selection != Some(next_selection);
*selection = Some(next_selection);
if input_fields[input_index].caret != next_caret {
input_fields[input_index].caret = next_caret;
outcome.caret_changed = true;
}
} else {
let input_field = &mut input_fields[input_index];
match &event.key {
KeyboardKey::Escape => {
outcome.selection_changed |= clear_input_selection_for(selection, input_field);
*focused_element = None;
outcome.focus_changed = true;
}
KeyboardKey::ArrowLeft | KeyboardKey::ArrowRight => {
if let Some((start, end)) =
active_input_selection_bounds(*selection, input_field)
{
input_field.caret = if matches!(&event.key, KeyboardKey::ArrowLeft) {
start
} else {
end
};
*selection = None;
outcome.caret_changed = true;
outcome.selection_changed = true;
} else if let Some(prepared_text) = prepared_text
&& let Some(next_caret) =
navigation_target_offset(prepared_text, input_field.caret, &event.key)
&& next_caret != input_field.caret
{
input_field.caret = next_caret;
outcome.caret_changed = true;
}
}
KeyboardKey::ArrowUp
| KeyboardKey::ArrowDown
| KeyboardKey::Home
| KeyboardKey::End => {
if let Some(prepared_text) = prepared_text
&& let Some(next_caret) =
navigation_target_offset(prepared_text, input_field.caret, &event.key)
&& next_caret != input_field.caret
{
input_field.caret = next_caret;
outcome.caret_changed = true;
}
outcome.selection_changed |= clear_input_selection_for(selection, input_field);
}
KeyboardKey::Backspace => {
if let Some(range) = active_input_selection_bounds(*selection, input_field) {
outcome = replace_input_range(input_field, selection, range, "");
} else if let Some(prepared_text) = prepared_text {
let previous = prepared_text.previous_char_boundary(input_field.caret);
if previous != input_field.caret {
input_field
.text
.replace_range(previous..input_field.caret, "");
input_field.caret = previous;
outcome.text_changed = true;
outcome.caret_changed = true;
}
}
}
KeyboardKey::Delete => {
if let Some(range) = active_input_selection_bounds(*selection, input_field) {
outcome = replace_input_range(input_field, selection, range, "");
} else if let Some(prepared_text) = prepared_text {
let next = prepared_text.next_char_boundary(input_field.caret);
if next != input_field.caret {
input_field.text.replace_range(input_field.caret..next, "");
outcome.text_changed = true;
}
}
}
KeyboardKey::Enter | KeyboardKey::Tab => {}
_ => {
let inserted_text =
event
.text
.clone()
.filter(|text| !text.is_empty())
.or_else(|| match &event.key {
KeyboardKey::Character(text) if !text.is_empty() => {
Some(text.clone())
}
_ => None,
});
if !event.modifiers.alt
&& !event.modifiers.super_key
&& let Some(text) = inserted_text
{
outcome = insert_text_into_focused_input(
*focused_element,
input_fields,
selection,
&text,
);
}
}
}
}
} else if event.modifiers.shift
&& let Some((current_selection, prepared_text)) =
selection_target_prepared_text(interaction_tree, *selection)
&& let Some(next_focus) = navigation_target_offset(
prepared_text,
selection_navigation_anchor_and_focus(current_selection, &event.key).1,
&event.key,
)
{
let next_selection = TextSelection {
element_id: current_selection.element_id,
anchor: selection_navigation_anchor_and_focus(current_selection, &event.key).0,
focus: next_focus,
};
outcome.selection_changed = *selection != Some(next_selection);
*selection = Some(next_selection);
} else {
tracing::trace!(
target: "ruin_ui_text_paragraph_demo::input",
keycode = event.keycode,
@@ -827,104 +1201,6 @@ fn handle_keyboard_input_event(
"ignored keyboard event because input is not focused"
);
return outcome;
};
match &event.key {
KeyboardKey::Escape => {
outcome.selection_changed |= clear_input_selection_for(selection, input_field);
*focused_element = None;
outcome.focus_changed = true;
}
KeyboardKey::ArrowLeft => {
if let Some((start, _)) = active_input_selection_bounds(*selection, input_field) {
input_field.caret = start;
*selection = None;
outcome.caret_changed = true;
outcome.selection_changed = true;
} else {
let next_caret = previous_char_boundary(&input_field.text, input_field.caret);
if next_caret != input_field.caret {
input_field.caret = next_caret;
outcome.caret_changed = true;
}
}
}
KeyboardKey::ArrowRight => {
if let Some((_, end)) = active_input_selection_bounds(*selection, input_field) {
input_field.caret = end;
*selection = None;
outcome.caret_changed = true;
outcome.selection_changed = true;
} else {
let next_caret = next_char_boundary(&input_field.text, input_field.caret);
if next_caret != input_field.caret {
input_field.caret = next_caret;
outcome.caret_changed = true;
}
}
}
KeyboardKey::Home => {
if input_field.caret != 0 {
input_field.caret = 0;
outcome.caret_changed = true;
}
outcome.selection_changed |= clear_input_selection_for(selection, input_field);
}
KeyboardKey::End => {
let end = input_field.text.len();
if input_field.caret != end {
input_field.caret = end;
outcome.caret_changed = true;
}
outcome.selection_changed |= clear_input_selection_for(selection, input_field);
}
KeyboardKey::Backspace => {
if let Some(range) = active_input_selection_bounds(*selection, input_field) {
return replace_input_range(input_field, selection, range, "");
}
let previous = previous_char_boundary(&input_field.text, input_field.caret);
if previous != input_field.caret {
input_field
.text
.replace_range(previous..input_field.caret, "");
input_field.caret = previous;
outcome.text_changed = true;
outcome.caret_changed = true;
}
}
KeyboardKey::Delete => {
if let Some(range) = active_input_selection_bounds(*selection, input_field) {
return replace_input_range(input_field, selection, range, "");
}
let next = next_char_boundary(&input_field.text, input_field.caret);
if next != input_field.caret {
input_field.text.replace_range(input_field.caret..next, "");
outcome.text_changed = true;
}
}
KeyboardKey::Enter | KeyboardKey::Tab => {}
_ => {
let inserted_text = event
.text
.clone()
.filter(|text| !text.is_empty())
.or_else(|| match &event.key {
KeyboardKey::Character(text) if !text.is_empty() => Some(text.clone()),
_ => None,
});
if !event.modifiers.control
&& !event.modifiers.alt
&& !event.modifiers.super_key
&& let Some(text) = inserted_text
{
return insert_text_into_focused_input(
*focused_element,
input_fields,
selection,
&text,
);
}
}
}
tracing::trace!(
@@ -952,25 +1228,6 @@ fn handle_keyboard_input_event(
outcome
}
fn previous_char_boundary(text: &str, offset: usize) -> usize {
let offset = offset.min(text.len());
text[..offset]
.char_indices()
.last()
.map(|(index, _)| index)
.unwrap_or(0)
}
fn next_char_boundary(text: &str, offset: usize) -> usize {
let offset = offset.min(text.len());
if offset >= text.len() {
return text.len();
}
text.char_indices()
.find_map(|(index, _)| (index > offset).then_some(index))
.unwrap_or(text.len())
}
fn schedule_caret_blink(
window: &WindowController,
caret_blink_timer: &mut Option<TimeoutHandle>,
@@ -1038,6 +1295,7 @@ fn scene_with_overlays(
}
if caret_visible
&& let Some(input_field) = focused_input(focused_element, input_fields)
&& !has_active_input_selection(selection, input_field)
&& let Some(prepared_text) = interaction_tree.text_for_element(input_field.text_id)
&& let Some(caret_rect) = prepared_text.caret_rect(input_field.caret, 2.0)
{