diff --git a/examples/text_paragraph_demo/assets/ruin.png b/examples/text_paragraph_demo/assets/ruin.png new file mode 100644 index 0000000..d87bc05 Binary files /dev/null and b/examples/text_paragraph_demo/assets/ruin.png differ diff --git a/examples/text_paragraph_demo/src/main.rs b/examples/text_paragraph_demo/src/main.rs index 3148132..42eefe5 100644 --- a/examples/text_paragraph_demo/src/main.rs +++ b/examples/text_paragraph_demo/src/main.rs @@ -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, } 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, input_field: &InputFieldState, @@ -168,6 +192,10 @@ fn active_input_selection_bounds( Some(selection_bounds(selection)) } +fn has_active_input_selection(selection: Option, input_field: &InputFieldState) -> bool { + active_input_selection_bounds(selection, input_field).is_some() +} + fn clear_input_selection_for( selection: &mut Option, input_field: &InputFieldState, @@ -221,6 +249,83 @@ fn insert_text_into_focused_input( } } +fn selection_text( + selection: Option, + prepared_text: &PreparedText, +) -> Option { + 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, +) -> 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 { + 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, + 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> { let mut caret_blink_token = 0_u64; let mut selection = None; let mut selection_drag = None; + let mut click_tracker = None::; println!("Opening RUIN paragraph demo window..."); window.set_cursor_icon(current_cursor)?; @@ -297,6 +403,7 @@ async fn main() -> Result<(), Box> { let mut pointer_events = Vec::new(); let mut keyboard_events = Vec::new(); let mut pending_blink_token = None::; + let mut pending_clipboard_text = None::; let mut pending_primary_selection_text = None::; let mut close_requested = false; let mut closed = false; @@ -326,6 +433,9 @@ async fn main() -> Result<(), Box> { 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> { let mut needs_hover_rebuild = false; let mut needs_input_rebuild = false; let mut needs_overlay_present = false; - let mut copied_text = None::; + let mut copied_primary_selection_text = None::; + let mut copied_clipboard_text = None::; 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> { 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> { } } } - 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> { 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, selection_drag: &mut Option, + click_tracker: &mut Option, ) -> 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, 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, @@ -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) { diff --git a/lib/ui/examples/explicit_ui_prototype.rs b/lib/ui/examples/explicit_ui_prototype.rs index e35b891..d4d974e 100644 --- a/lib/ui/examples/explicit_ui_prototype.rs +++ b/lib/ui/examples/explicit_ui_prototype.rs @@ -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", diff --git a/lib/ui/src/platform.rs b/lib/ui/src/platform.rs index f1cd3c2..65d98f5 100644 --- a/lib/ui/src/platform.rs +++ b/lib/ui/src/platform.rs @@ -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, + ) -> 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 { .. } => {} diff --git a/lib/ui/src/runtime.rs b/lib/ui/src/runtime.rs index 2064a86..45e3e6f 100644 --- a/lib/ui/src/runtime.rs +++ b/lib/ui/src/runtime.rs @@ -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) -> 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) diff --git a/lib/ui/src/scene.rs b/lib/ui/src/scene.rs index 1efa56e..59de791 100644 --- a/lib/ui/src/scene.rs +++ b/lib/ui/src/scene.rs @@ -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 { + Some(self.line_for_offset(offset)?.text_start) + } + + pub fn line_end_offset(&self, offset: usize) -> Option { + Some(self.line_for_offset(offset)?.text_end) + } + + pub fn vertical_offset(&self, offset: usize, line_delta: isize) -> Option { + 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 { + 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)); + } } diff --git a/lib/ui_platform_wayland/src/lib.rs b/lib/ui_platform_wayland/src/lib.rs index 1dd0a9e..3995523 100644 --- a/lib/ui_platform_wayland/src/lib.rs +++ b/lib/ui_platform_wayland/src/lib.rs @@ -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, + clipboard_device: Option, cursor_shape_manager: Option, cursor_shape_device: Option, primary_selection_manager: @@ -161,6 +166,10 @@ struct State { keyboard_repeat_rate: i32, keyboard_repeat_delay: Duration, keyboard_repeat: Option, + clipboard_source: Option, + clipboard_text: Option, + clipboard_offer: Option, + clipboard_offer_mime_types: Vec, primary_selection_source: Option, primary_selection_text: Option, primary_selection_offer: Option, @@ -234,6 +243,14 @@ fn keyboard_modifiers_from_xkb(state: &xkb::State) -> KeyboardModifiers { } } +fn preferred_plain_text_mime(mime_types: &[String]) -> Option { + 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 { 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) -> Result<(), Box> { + 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, Box> { + 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, @@ -532,18 +619,7 @@ impl WaylandWindow { } pub fn read_primary_selection_text(&mut self) -> Result, Box> { - 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>, + 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>, window_id: WindowId, @@ -869,6 +967,18 @@ fn handle_request_primary_selection_text( } } +fn handle_request_clipboard_text(state: &Rc>, 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>, 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 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 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 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 for State { } } +impl Dispatch for State { + fn event( + state: &mut Self, + _data_device: &wl_data_device::WlDataDevice, + event: wl_data_device::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + 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 for State { + fn event( + state: &mut Self, + offer: &wl_data_offer::WlDataOffer, + event: wl_data_offer::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + 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 for State { + fn event( + state: &mut Self, + data_source: &wl_data_source::WlDataSource, + event: wl_data_source::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + 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 for State { fn event( state: &mut Self,