Better text selection
This commit is contained in:
BIN
examples/text_paragraph_demo/assets/ruin.png
Normal file
BIN
examples/text_paragraph_demo/assets/ruin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 754 KiB |
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ use tracing::Level;
|
||||
use tracing::{debug, trace};
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
use wayland_client::protocol::{
|
||||
wl_callback, wl_compositor, wl_keyboard, wl_pointer, wl_registry, wl_seat, wl_surface,
|
||||
wl_callback, wl_compositor, wl_data_device, wl_data_device_manager, wl_data_offer,
|
||||
wl_data_source, wl_keyboard, wl_pointer, wl_registry, wl_seat, wl_surface,
|
||||
};
|
||||
use wayland_client::{
|
||||
Connection, Dispatch, Proxy, QueueHandle, WEnum, delegate_noop, event_created_child,
|
||||
@@ -117,6 +118,8 @@ struct WindowWorkerState {
|
||||
|
||||
enum WindowWorkerCommand {
|
||||
ReplaceScene(SceneSnapshot),
|
||||
SetClipboardText(String),
|
||||
RequestClipboardText,
|
||||
SetPrimarySelectionText(String),
|
||||
RequestPrimarySelectionText,
|
||||
SetCursorIcon(CursorIcon),
|
||||
@@ -137,6 +140,8 @@ struct State {
|
||||
_toplevel: xdg_toplevel::XdgToplevel,
|
||||
_wm_base: xdg_wm_base::XdgWmBase,
|
||||
_seat: wl_seat::WlSeat,
|
||||
clipboard_manager: Option<wl_data_device_manager::WlDataDeviceManager>,
|
||||
clipboard_device: Option<wl_data_device::WlDataDevice>,
|
||||
cursor_shape_manager: Option<wp_cursor_shape_manager_v1::WpCursorShapeManagerV1>,
|
||||
cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
|
||||
primary_selection_manager:
|
||||
@@ -161,6 +166,10 @@ struct State {
|
||||
keyboard_repeat_rate: i32,
|
||||
keyboard_repeat_delay: Duration,
|
||||
keyboard_repeat: Option<KeyboardRepeatState>,
|
||||
clipboard_source: Option<wl_data_source::WlDataSource>,
|
||||
clipboard_text: Option<String>,
|
||||
clipboard_offer: Option<wl_data_offer::WlDataOffer>,
|
||||
clipboard_offer_mime_types: Vec<String>,
|
||||
primary_selection_source: Option<zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1>,
|
||||
primary_selection_text: Option<String>,
|
||||
primary_selection_offer: Option<zwp_primary_selection_offer_v1::ZwpPrimarySelectionOfferV1>,
|
||||
@@ -234,6 +243,14 @@ fn keyboard_modifiers_from_xkb(state: &xkb::State) -> KeyboardModifiers {
|
||||
}
|
||||
}
|
||||
|
||||
fn preferred_plain_text_mime(mime_types: &[String]) -> Option<String> {
|
||||
mime_types
|
||||
.iter()
|
||||
.find(|mime| mime.as_str() == "text/plain;charset=utf-8")
|
||||
.or_else(|| mime_types.iter().find(|mime| mime.as_str() == "text/plain"))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn keyboard_text_for_xkb(state: &xkb::State, keycode: xkb::Keycode) -> Option<String> {
|
||||
let text = state.key_get_utf8(keycode);
|
||||
if text.is_empty() || text.chars().any(char::is_control) {
|
||||
@@ -276,6 +293,12 @@ impl WaylandWindow {
|
||||
let compositor: wl_compositor::WlCompositor = globals.bind(&qh, 4..=6, ())?;
|
||||
let seat: wl_seat::WlSeat = globals.bind(&qh, 1..=9, ())?;
|
||||
let wm_base: xdg_wm_base::XdgWmBase = globals.bind(&qh, 1..=6, ())?;
|
||||
let clipboard_manager = globals.bind(&qh, 1..=3, ()).ok();
|
||||
let clipboard_device = clipboard_manager
|
||||
.as_ref()
|
||||
.map(|manager: &wl_data_device_manager::WlDataDeviceManager| {
|
||||
manager.get_data_device(&seat, &qh, ())
|
||||
});
|
||||
let cursor_shape_manager = globals.bind(&qh, 1..=2, ()).ok();
|
||||
let primary_selection_manager = globals.bind(&qh, 1..=1, ()).ok();
|
||||
let primary_selection_device = primary_selection_manager.as_ref().map(
|
||||
@@ -324,6 +347,8 @@ impl WaylandWindow {
|
||||
_toplevel: toplevel,
|
||||
_wm_base: wm_base,
|
||||
_seat: seat,
|
||||
clipboard_manager,
|
||||
clipboard_device,
|
||||
cursor_shape_manager,
|
||||
cursor_shape_device: None,
|
||||
primary_selection_manager,
|
||||
@@ -347,6 +372,10 @@ impl WaylandWindow {
|
||||
keyboard_repeat_rate: 25,
|
||||
keyboard_repeat_delay: Duration::from_millis(500),
|
||||
keyboard_repeat: None,
|
||||
clipboard_source: None,
|
||||
clipboard_text: None,
|
||||
clipboard_offer: None,
|
||||
clipboard_offer_mime_types: Vec::new(),
|
||||
primary_selection_source: None,
|
||||
primary_selection_text: None,
|
||||
primary_selection_offer: None,
|
||||
@@ -496,6 +525,64 @@ impl WaylandWindow {
|
||||
self.state.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_clipboard_text(&mut self, text: impl Into<String>) -> Result<(), Box<dyn Error>> {
|
||||
let text = text.into();
|
||||
let Some(clipboard_manager) = self.state.clipboard_manager.as_ref() else {
|
||||
debug!(
|
||||
target: "ruin_ui_platform_wayland::clipboard",
|
||||
"Wayland compositor does not expose wl_data_device_manager; skipping clipboard copy"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let Some(clipboard_device) = self.state.clipboard_device.as_ref() else {
|
||||
debug!(
|
||||
target: "ruin_ui_platform_wayland::clipboard",
|
||||
"Wayland seat does not expose a clipboard data device; skipping clipboard copy"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let Some(serial) = self.state.last_selection_serial else {
|
||||
return Err(Box::new(std::io::Error::other(
|
||||
"clipboard copy requires a recent input serial",
|
||||
)));
|
||||
};
|
||||
|
||||
let source = clipboard_manager.create_data_source(&self.state.qh, ());
|
||||
source.offer("text/plain;charset=utf-8".to_owned());
|
||||
source.offer("text/plain".to_owned());
|
||||
clipboard_device.set_selection(Some(&source), serial);
|
||||
self.state.clipboard_source = Some(source);
|
||||
self.state.clipboard_text = Some(text);
|
||||
self.state._connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_clipboard_text(&mut self) -> Result<Option<String>, Box<dyn Error>> {
|
||||
let preferred_mime = preferred_plain_text_mime(&self.state.clipboard_offer_mime_types);
|
||||
let Some(mime_type) = preferred_mime else {
|
||||
return Ok(self.state.clipboard_text.clone());
|
||||
};
|
||||
let Some(offer) = self.state.clipboard_offer.as_ref() else {
|
||||
return Ok(self.state.clipboard_text.clone());
|
||||
};
|
||||
|
||||
let mut pipe_fds = [0; 2];
|
||||
let pipe_result = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) };
|
||||
if pipe_result != 0 {
|
||||
return Err(Box::new(std::io::Error::last_os_error()));
|
||||
}
|
||||
|
||||
let read_fd = unsafe { OwnedFd::from_raw_fd(pipe_fds[0]) };
|
||||
let write_fd = unsafe { OwnedFd::from_raw_fd(pipe_fds[1]) };
|
||||
offer.receive(mime_type, write_fd.as_fd());
|
||||
self.state._connection.flush()?;
|
||||
drop(write_fd);
|
||||
let mut file = File::from(read_fd);
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)?;
|
||||
Ok((!text.is_empty()).then_some(text))
|
||||
}
|
||||
|
||||
pub fn set_primary_selection_text(
|
||||
&mut self,
|
||||
text: impl Into<String>,
|
||||
@@ -532,18 +619,7 @@ impl WaylandWindow {
|
||||
}
|
||||
|
||||
pub fn read_primary_selection_text(&mut self) -> Result<Option<String>, Box<dyn Error>> {
|
||||
let preferred_mime = self
|
||||
.state
|
||||
.primary_selection_offer_mime_types
|
||||
.iter()
|
||||
.find(|mime| mime.as_str() == "text/plain;charset=utf-8")
|
||||
.or_else(|| {
|
||||
self.state
|
||||
.primary_selection_offer_mime_types
|
||||
.iter()
|
||||
.find(|mime| mime.as_str() == "text/plain")
|
||||
})
|
||||
.cloned();
|
||||
let preferred_mime = preferred_plain_text_mime(&self.state.primary_selection_offer_mime_types);
|
||||
let Some(mime_type) = preferred_mime else {
|
||||
return Ok(self.state.primary_selection_text.clone());
|
||||
};
|
||||
@@ -677,6 +753,12 @@ fn run_wayland_platform(mut endpoint: PlatformEndpoint) {
|
||||
PlatformRequest::ReplaceScene { window_id, scene } => {
|
||||
handle_replace_scene(&state, window_id, scene);
|
||||
}
|
||||
PlatformRequest::SetClipboardText { window_id, text } => {
|
||||
handle_set_clipboard_text(&state, window_id, text);
|
||||
}
|
||||
PlatformRequest::RequestClipboardText { window_id } => {
|
||||
handle_request_clipboard_text(&state, window_id);
|
||||
}
|
||||
PlatformRequest::SetPrimarySelectionText { window_id, text } => {
|
||||
handle_set_primary_selection_text(&state, window_id, text);
|
||||
}
|
||||
@@ -854,6 +936,22 @@ fn handle_set_primary_selection_text(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_set_clipboard_text(
|
||||
state: &Rc<RefCell<WaylandBackendState>>,
|
||||
window_id: WindowId,
|
||||
text: String,
|
||||
) {
|
||||
let command_tx = state.borrow().windows.get(&window_id).and_then(|record| {
|
||||
record
|
||||
.worker
|
||||
.as_ref()
|
||||
.map(|worker| worker.command_tx.clone())
|
||||
});
|
||||
if let Some(command_tx) = command_tx {
|
||||
let _ = command_tx.send(WindowWorkerCommand::SetClipboardText(text));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request_primary_selection_text(
|
||||
state: &Rc<RefCell<WaylandBackendState>>,
|
||||
window_id: WindowId,
|
||||
@@ -869,6 +967,18 @@ fn handle_request_primary_selection_text(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request_clipboard_text(state: &Rc<RefCell<WaylandBackendState>>, window_id: WindowId) {
|
||||
if let Some(command_tx) = state
|
||||
.borrow()
|
||||
.windows
|
||||
.get(&window_id)
|
||||
.and_then(|record| record.worker.as_ref())
|
||||
.map(|worker| worker.command_tx.clone())
|
||||
{
|
||||
let _ = command_tx.send(WindowWorkerCommand::RequestClipboardText);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_set_cursor_icon(
|
||||
state: &Rc<RefCell<WaylandBackendState>>,
|
||||
window_id: WindowId,
|
||||
@@ -972,6 +1082,37 @@ fn spawn_window_worker(
|
||||
state_ref.latest_scene = Some(scene);
|
||||
state_ref.window.request_redraw();
|
||||
}
|
||||
WindowWorkerCommand::SetClipboardText(text) => {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
if let Err(error) = state_ref.window.set_clipboard_text(text) {
|
||||
debug!(
|
||||
target: "ruin_ui_platform_wayland::clipboard",
|
||||
window_id = state_ref.window_id.raw(),
|
||||
error = %error,
|
||||
"failed to set clipboard text"
|
||||
);
|
||||
}
|
||||
}
|
||||
WindowWorkerCommand::RequestClipboardText => {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
match state_ref.window.read_clipboard_text() {
|
||||
Ok(Some(text)) => {
|
||||
let _ = state_ref.event_tx.send(PlatformEvent::ClipboardText {
|
||||
window_id: state_ref.window_id,
|
||||
text,
|
||||
});
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
debug!(
|
||||
target: "ruin_ui_platform_wayland::clipboard",
|
||||
window_id = state_ref.window_id.raw(),
|
||||
error = %error,
|
||||
"failed to read clipboard text"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowWorkerCommand::SetPrimarySelectionText(text) => {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
if let Err(error) =
|
||||
@@ -1284,6 +1425,7 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
|
||||
|
||||
delegate_noop!(State: ignore wl_compositor::WlCompositor);
|
||||
delegate_noop!(State: ignore wl_surface::WlSurface);
|
||||
delegate_noop!(State: ignore wl_data_device_manager::WlDataDeviceManager);
|
||||
delegate_noop!(State: ignore wp_cursor_shape_manager_v1::WpCursorShapeManagerV1);
|
||||
delegate_noop!(State: ignore wp_cursor_shape_device_v1::WpCursorShapeDeviceV1);
|
||||
delegate_noop!(
|
||||
@@ -1516,6 +1658,7 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for State {
|
||||
state.keyboard_modifiers = keyboard_modifiers_from_xkb(xkb_state);
|
||||
}
|
||||
wl_keyboard::Event::Key {
|
||||
serial,
|
||||
key,
|
||||
state: key_state,
|
||||
..
|
||||
@@ -1547,6 +1690,9 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for State {
|
||||
}
|
||||
WEnum::Value(_) | WEnum::Unknown(_) => return,
|
||||
};
|
||||
if matches!(kind, KeyboardEventKind::Pressed) {
|
||||
state.last_selection_serial = Some(serial);
|
||||
}
|
||||
let keycode = xkb::Keycode::new(key + 8);
|
||||
xkb_state.update_key(keycode, direction);
|
||||
state.keyboard_modifiers = keyboard_modifiers_from_xkb(xkb_state);
|
||||
@@ -1598,6 +1744,72 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for State {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_data_device::WlDataDevice, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_data_device: &wl_data_device::WlDataDevice,
|
||||
event: wl_data_device::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_data_device::Event::Selection { id } = event {
|
||||
state.clipboard_offer = id;
|
||||
state.clipboard_offer_mime_types.clear();
|
||||
}
|
||||
}
|
||||
|
||||
event_created_child!(State, wl_data_device::WlDataDevice, [
|
||||
wl_data_device::EVT_DATA_OFFER_OPCODE => (wl_data_offer::WlDataOffer, ())
|
||||
]);
|
||||
}
|
||||
|
||||
impl Dispatch<wl_data_offer::WlDataOffer, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
offer: &wl_data_offer::WlDataOffer,
|
||||
event: wl_data_offer::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_data_offer::Event::Offer { mime_type } = event
|
||||
&& state.clipboard_offer.as_ref() == Some(offer)
|
||||
{
|
||||
state.clipboard_offer_mime_types.push(mime_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_data_source::WlDataSource, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
data_source: &wl_data_source::WlDataSource,
|
||||
event: wl_data_source::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wl_data_source::Event::Send { mime_type, fd } => {
|
||||
if mime_type == "text/plain" || mime_type == "text/plain;charset=utf-8" {
|
||||
let mut file = File::from(fd);
|
||||
if let Some(text) = state.clipboard_text.as_deref() {
|
||||
let _ = file.write_all(text.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
wl_data_source::Event::Cancelled => {
|
||||
if state.clipboard_source.as_ref() == Some(data_source) {
|
||||
state.clipboard_source = None;
|
||||
state.clipboard_text = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
|
||||
Reference in New Issue
Block a user