From 84077b718fca7e0309180ff357f431ba8fab5917 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Sat, 21 Mar 2026 01:17:07 -0400 Subject: [PATCH] Keyboard input, text input elements --- Cargo.lock | 18 + examples/text_paragraph_demo/src/main.rs | 720 ++++++++++++++++++++++- lib/ui/examples/explicit_ui_prototype.rs | 28 + lib/ui/src/interaction.rs | 7 + lib/ui/src/keyboard.rs | 57 ++ lib/ui/src/layout.rs | 6 + lib/ui/src/lib.rs | 2 + lib/ui/src/platform.rs | 52 ++ lib/ui/src/runtime.rs | 69 ++- lib/ui/src/scene.rs | 75 +++ lib/ui/src/tree.rs | 7 + lib/ui_platform_wayland/Cargo.toml | 1 + lib/ui_platform_wayland/src/lib.rs | 461 ++++++++++++++- 13 files changed, 1451 insertions(+), 52 deletions(-) create mode 100644 lib/ui/src/keyboard.rs diff --git a/Cargo.lock b/Cargo.lock index a6375c7..e6ecdf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1016,6 +1016,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-protocols", + "xkbcommon", ] [[package]] @@ -1850,6 +1851,23 @@ dependencies = [ "windows-link", ] +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "xml-rs" version = "0.8.28" diff --git a/examples/text_paragraph_demo/src/main.rs b/examples/text_paragraph_demo/src/main.rs index e385e30..3148132 100644 --- a/examples/text_paragraph_demo/src/main.rs +++ b/examples/text_paragraph_demo/src/main.rs @@ -1,12 +1,14 @@ use std::error::Error; use std::process::Command; -use std::time::Instant; +use std::time::{Duration, Instant}; +use ruin_runtime::{TimeoutHandle, clear_timeout, set_timeout}; use ruin_ui::{ - Color, CursorIcon, DisplayItem, Edges, Element, ElementId, InteractionTree, LayoutSnapshot, - PlatformEvent, PointerButton, PointerEvent, PointerEventKind, PointerRouter, Quad, - RoutedPointerEventKind, SceneSnapshot, TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, - TextSpanSlant, TextSpanWeight, TextStyle, TextSystem, UiSize, WindowSpec, WindowUpdate, + 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, layout_snapshot_with_text_system, }; use ruin_ui_platform_wayland::start_wayland_ui; @@ -25,6 +27,11 @@ const STATUS_CARD_ID: ElementId = ElementId::new(6); const HERO_TITLE_ID: ElementId = ElementId::new(101); const HERO_BODY_ID: ElementId = ElementId::new(102); const RUST_LINK_ID: ElementId = ElementId::new(103); +const INPUT_FIELD_ID: ElementId = ElementId::new(104); +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 DEMO_SELECTION_STYLE: TextSelectionStyle = TextSelectionStyle::new(Color::rgba(0x6C, 0x8E, 0xFF, 0xB8)) .with_text_color(Color::rgb(0x0D, 0x14, 0x25)); @@ -67,6 +74,153 @@ struct SelectionOutcome { copied_text: Option, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct InputFieldState { + field_id: ElementId, + text_id: ElementId, + label: &'static str, + placeholder: &'static str, + text: String, + caret: usize, +} + +impl InputFieldState { + fn new( + field_id: ElementId, + text_id: ElementId, + label: &'static str, + placeholder: &'static str, + text: impl Into, + ) -> Self { + let text = text.into(); + let caret = text.len(); + Self { + field_id, + text_id, + label, + placeholder, + text, + caret, + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct InputOutcome { + focus_changed: bool, + text_changed: bool, + caret_changed: bool, + selection_changed: bool, + request_primary_paste: bool, +} + +fn focused_input_index( + focused_element: Option, + input_fields: &[InputFieldState], +) -> Option { + focused_element.and_then(|focused| { + input_fields + .iter() + .position(|field| field.field_id == focused) + }) +} + +fn focused_input( + focused_element: Option, + input_fields: &[InputFieldState], +) -> Option<&InputFieldState> { + focused_input_index(focused_element, input_fields).map(|index| &input_fields[index]) +} + +fn focused_input_mut( + focused_element: Option, + input_fields: &mut [InputFieldState], +) -> Option<&mut InputFieldState> { + focused_input_index(focused_element, input_fields).map(|index| &mut input_fields[index]) +} + +fn next_input_focus( + focused_element: Option, + input_fields: &[InputFieldState], +) -> ElementId { + match focused_input_index(focused_element, input_fields) { + Some(index) => input_fields[(index + 1) % input_fields.len()].field_id, + None => input_fields[0].field_id, + } +} + +fn selection_bounds(selection: TextSelection) -> (usize, usize) { + if selection.anchor <= selection.focus { + (selection.anchor, selection.focus) + } else { + (selection.focus, selection.anchor) + } +} + +fn active_input_selection_bounds( + selection: Option, + input_field: &InputFieldState, +) -> Option<(usize, usize)> { + let selection = selection?; + if selection.element_id != input_field.text_id || selection.is_collapsed() { + return None; + } + Some(selection_bounds(selection)) +} + +fn clear_input_selection_for( + selection: &mut Option, + input_field: &InputFieldState, +) -> bool { + if selection.is_some_and(|current| current.element_id == input_field.text_id) { + *selection = None; + return true; + } + false +} + +fn replace_input_range( + input_field: &mut InputFieldState, + selection: &mut Option, + range: (usize, usize), + replacement: &str, +) -> InputOutcome { + let (start, end) = range; + input_field.text.replace_range(start..end, replacement); + input_field.caret = start + replacement.len(); + *selection = None; + InputOutcome { + text_changed: true, + caret_changed: true, + selection_changed: true, + ..InputOutcome::default() + } +} + +fn insert_text_into_focused_input( + focused_element: Option, + input_fields: &mut [InputFieldState], + selection: &mut Option, + text: &str, +) -> InputOutcome { + let Some(input_field) = focused_input_mut(focused_element, input_fields) else { + return InputOutcome::default(); + }; + if let Some(range) = active_input_selection_bounds(*selection, input_field) { + return replace_input_range(input_field, selection, range, text); + } + let caret = input_field.caret; + input_field.text.insert_str(caret, text); + input_field.caret += text.len(); + let selection_changed = clear_input_selection_for(selection, input_field); + InputOutcome { + text_changed: true, + caret_changed: true, + selection_changed, + ..InputOutcome::default() + } +} + fn install_tracing() { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { EnvFilter::new( @@ -107,16 +261,43 @@ async fn main() -> Result<(), Box> { let mut in_flight_resize = None; let mut latest_submitted_viewport = None; let mut current_cursor = CursorIcon::Default; + let mut focused_element = None::; + let mut input_fields = [ + InputFieldState::new( + INPUT_FIELD_ID, + INPUT_TEXT_ID, + "Primary input", + "Click or press Tab, then type...", + "", + ), + InputFieldState::new( + SECOND_INPUT_FIELD_ID, + SECOND_INPUT_TEXT_ID, + "Secondary input", + "Use selection, Backspace, and middle-click paste here too...", + "", + ), + ]; + let mut caret_visible = false; + let mut caret_blink_timer = None::; + let mut caret_blink_token = 0_u64; let mut selection = None; let mut selection_drag = None; println!("Opening RUIN paragraph demo window..."); window.set_cursor_icon(current_cursor)?; - while let Some(event) = ui.next_event().await { + loop { + let Some(event) = ui.next_event().await else { + break; + }; + let mut latest_configuration = None; let mut resize_presented = false; let mut pointer_events = Vec::new(); + let mut keyboard_events = Vec::new(); + let mut pending_blink_token = None::; + let mut pending_primary_selection_text = None::; let mut close_requested = false; let mut closed = false; @@ -131,6 +312,25 @@ async fn main() -> Result<(), Box> { PlatformEvent::Pointer { window_id, event } if window_id == window.id() => { pointer_events.push(event); } + PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => { + tracing::trace!( + target: "ruin_ui_text_paragraph_demo::input", + keycode = event.keycode, + ?event.kind, + ?event.key, + text = event.text.as_deref().unwrap_or(""), + "received platform keyboard event" + ); + keyboard_events.push(event); + } + PlatformEvent::Wake { window_id, token } if window_id == window.id() => { + pending_blink_token = Some(token); + } + PlatformEvent::PrimarySelectionText { window_id, text } + if window_id == window.id() => + { + pending_primary_selection_text = Some(text); + } PlatformEvent::FramePresented { window_id, scene_version, @@ -153,11 +353,15 @@ async fn main() -> Result<(), Box> { } } - if latest_configuration.is_some() || !pointer_events.is_empty() { + if latest_configuration.is_some() + || !pointer_events.is_empty() + || !keyboard_events.is_empty() + { tracing::trace!( target: "ruin_ui_text_paragraph_demo::events", has_configured = latest_configuration.is_some(), pointer_events = pointer_events.len(), + keyboard_events = keyboard_events.len(), "processing coalesced event batch" ); } @@ -204,7 +408,14 @@ async fn main() -> Result<(), Box> { let LayoutSnapshot { scene, interaction_tree: next_interaction_tree, - } = build_snapshot(viewport, version, hovered_card, &mut text_system); + } = build_snapshot( + viewport, + version, + hovered_card, + &input_fields, + focused_element, + &mut text_system, + ); if selection.is_some_and(|selection: TextSelection| { next_interaction_tree .text_for_element(selection.element_id) @@ -221,7 +432,15 @@ async fn main() -> Result<(), Box> { build_ms = build_started.elapsed().as_secs_f64() * 1_000.0, "finished snapshot rebuild for configured size" ); - window.replace_scene(scene_with_selection(&scene, selection, version))?; + window.replace_scene(scene_with_overlays( + &scene, + &next_interaction_tree, + selection, + focused_element, + &input_fields, + caret_visible, + version, + ))?; base_scene = Some(scene); latest_submitted_viewport = Some(viewport); in_flight_resize = Some(InFlightResize { @@ -232,19 +451,32 @@ async fn main() -> Result<(), Box> { } let mut needs_hover_rebuild = false; - let mut needs_selection_present = false; + let mut needs_input_rebuild = false; + let mut needs_overlay_present = false; let mut copied_text = None::; + let mut request_primary_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() { for event in pointer_events { + let input_outcome = handle_input_focus_event( + current_interaction_tree, + event, + &mut focused_element, + &mut input_fields, + &mut selection, + ); + needs_input_rebuild |= input_outcome.focus_changed; + needs_overlay_present |= + input_outcome.caret_changed || input_outcome.selection_changed; + request_primary_paste |= input_outcome.request_primary_paste; let selection_outcome = handle_selection_event( current_interaction_tree, event, &mut selection, &mut selection_drag, ); - needs_selection_present |= selection_outcome.changed; + needs_overlay_present |= selection_outcome.changed; if selection_outcome.copied_text.is_some() { copied_text = selection_outcome.copied_text; } @@ -283,10 +515,52 @@ async fn main() -> Result<(), Box> { } } } + for event in keyboard_events { + let input_outcome = handle_keyboard_input_event( + 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; + } + if let Some(text) = pending_primary_selection_text { + let input_outcome = insert_text_into_focused_input( + focused_element, + &mut input_fields, + &mut selection, + &text, + ); + needs_input_rebuild |= input_outcome.text_changed; + needs_overlay_present |= input_outcome.caret_changed || input_outcome.selection_changed; + } + if needs_input_rebuild || needs_overlay_present { + reset_caret_blink( + &window, + focused_element, + &input_fields, + &mut caret_visible, + &mut caret_blink_timer, + &mut caret_blink_token, + ); + } + if pending_blink_token == Some(caret_blink_token) + && focused_input(focused_element, &input_fields).is_some() + { + schedule_caret_blink(&window, &mut caret_blink_timer, caret_blink_token); + if in_flight_resize.is_none() { + caret_visible = !caret_visible; + needs_overlay_present = true; + } + } if let Some(copied_text) = copied_text { window.set_primary_selection_text(copied_text)?; } - if needs_hover_rebuild && in_flight_resize.is_none() { + if request_primary_paste { + window.request_primary_selection_text()?; + } + if (needs_hover_rebuild || needs_input_rebuild) && in_flight_resize.is_none() { version = version.wrapping_add(1); tracing::trace!( target: "ruin_ui_text_paragraph_demo::hover", @@ -297,7 +571,14 @@ async fn main() -> Result<(), Box> { let LayoutSnapshot { scene, interaction_tree: next_interaction_tree, - } = build_snapshot(viewport, version, hovered_card, &mut text_system); + } = build_snapshot( + viewport, + version, + hovered_card, + &input_fields, + focused_element, + &mut text_system, + ); if selection.is_some_and(|selection: TextSelection| { next_interaction_tree .text_for_element(selection.element_id) @@ -306,15 +587,32 @@ async fn main() -> Result<(), Box> { selection = None; selection_drag = None; } - window.replace_scene(scene_with_selection(&scene, selection, version))?; + window.replace_scene(scene_with_overlays( + &scene, + &next_interaction_tree, + selection, + focused_element, + &input_fields, + caret_visible, + version, + ))?; base_scene = Some(scene); interaction_tree = Some(next_interaction_tree); - } else if needs_selection_present + } else if needs_overlay_present && in_flight_resize.is_none() && let Some(base_scene) = base_scene.as_ref() + && let Some(current_interaction_tree) = interaction_tree.as_ref() { version = version.wrapping_add(1); - window.replace_scene(scene_with_selection(base_scene, selection, version))?; + window.replace_scene(scene_with_overlays( + base_scene, + current_interaction_tree, + selection, + focused_element, + &input_fields, + caret_visible, + version, + ))?; } if close_requested { @@ -333,9 +631,11 @@ fn build_snapshot( viewport: UiSize, version: u64, hovered_card: Option, + input_fields: &[InputFieldState], + focused_element: Option, text_system: &mut TextSystem, ) -> LayoutSnapshot { - let tree = build_document_tree(viewport, hovered_card); + let tree = build_document_tree(viewport, hovered_card, input_fields, focused_element); layout_snapshot_with_text_system(version, viewport, &tree, text_system) } @@ -347,7 +647,9 @@ fn handle_selection_event( ) -> SelectionOutcome { let mut outcome = SelectionOutcome::default(); match event.kind { - PointerEventKind::Down { .. } => { + PointerEventKind::Down { + button: PointerButton::Primary, + } => { let next_selection = interaction_tree .text_hit_test(event.position) .and_then(|hit| { @@ -382,7 +684,9 @@ fn handle_selection_event( *selection = Some(next_selection); } } - PointerEventKind::Up { .. } => { + PointerEventKind::Up { + button: PointerButton::Primary, + } => { let Some(drag) = selection_drag.take() else { return outcome; }; @@ -405,27 +709,318 @@ fn handle_selection_event( *selection = Some(next_selection); } } + PointerEventKind::Down { .. } | PointerEventKind::Up { .. } => {} PointerEventKind::LeaveWindow => {} } outcome } -fn scene_with_selection( - base_scene: &SceneSnapshot, - selection: Option, - version: u64, -) -> SceneSnapshot { - let Some(selection) = selection.filter(|selection| !selection.is_collapsed()) else { - let mut scene = base_scene.clone(); - scene.version = version; - return scene; +fn handle_input_focus_event( + interaction_tree: &InteractionTree, + event: PointerEvent, + focused_element: &mut Option, + input_fields: &mut [InputFieldState], + selection: &mut Option, +) -> InputOutcome { + let mut outcome = InputOutcome::default(); + let is_middle_click = matches!( + event.kind, + PointerEventKind::Down { + button: PointerButton::Middle + } + ); + let is_primary_click = matches!( + event.kind, + PointerEventKind::Down { + button: PointerButton::Primary + } + ); + if !is_primary_click && !is_middle_click { + return outcome; + } + + let next_focus = interaction_tree + .hit_path(event.position) + .iter() + .rev() + .find_map(|target| target.focusable.then_some(target.element_id).flatten()); + if next_focus != *focused_element { + *focused_element = next_focus; + outcome.focus_changed = true; + } + + if let Some(input_field) = focused_input_mut(next_focus, input_fields) { + let next_caret = interaction_tree + .text_for_element(input_field.text_id) + .map(|prepared_text| prepared_text.byte_offset_for_position(event.position)) + .unwrap_or(input_field.text.len()); + if next_caret != input_field.caret { + input_field.caret = next_caret; + outcome.caret_changed = true; + } + outcome.selection_changed |= clear_input_selection_for(selection, input_field); + outcome.request_primary_paste = is_middle_click; + } + + if outcome.focus_changed || outcome.caret_changed || outcome.request_primary_paste { + tracing::trace!( + target: "ruin_ui_text_paragraph_demo::input", + pointer_x = event.position.x, + pointer_y = event.position.y, + focused = focused_element.map(|id| id.raw()), + caret = focused_input(*focused_element, input_fields) + .map(|input_field| input_field.caret) + .unwrap_or(0), + focus_changed = outcome.focus_changed, + caret_changed = outcome.caret_changed, + request_primary_paste = outcome.request_primary_paste, + "processed pointer focus event" + ); + } + + outcome +} + +fn handle_keyboard_input_event( + event: KeyboardEvent, + focused_element: &mut Option, + input_fields: &mut [InputFieldState], + selection: &mut Option, +) -> InputOutcome { + let mut outcome = InputOutcome::default(); + let focus_before = *focused_element; + let text_len_before = focused_input(*focused_element, input_fields) + .map(|input_field| input_field.text.len()) + .unwrap_or(0); + let caret_before = focused_input(*focused_element, input_fields) + .map(|input_field| input_field.caret) + .unwrap_or(0); + if event.kind != KeyboardEventKind::Pressed { + return outcome; + } + + if matches!(&event.key, KeyboardKey::Tab) { + *focused_element = Some(next_input_focus(*focused_element, input_fields)); + if let Some(input_field) = focused_input_mut(*focused_element, input_fields) { + outcome.selection_changed |= clear_input_selection_for(selection, input_field); + } + outcome.focus_changed = true; + tracing::trace!( + target: "ruin_ui_text_paragraph_demo::input", + keycode = event.keycode, + ?event.key, + text = event.text.as_deref().unwrap_or(""), + focus_before = focus_before.map(|id| id.raw()), + focus_after = focused_element.map(|id| id.raw()), + "focused input from keyboard" + ); + return outcome; + } + + let Some(input_field) = focused_input_mut(*focused_element, input_fields) else { + tracing::trace!( + target: "ruin_ui_text_paragraph_demo::input", + keycode = event.keycode, + ?event.key, + text = event.text.as_deref().unwrap_or(""), + focus_before = focus_before.map(|id| id.raw()), + "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!( + target: "ruin_ui_text_paragraph_demo::input", + keycode = event.keycode, + ?event.key, + text = event.text.as_deref().unwrap_or(""), + focus_before = focus_before.map(|id| id.raw()), + focus_after = focused_element.map(|id| id.raw()), + text_len_before, + text_len_after = focused_input(*focused_element, input_fields) + .map(|input_field| input_field.text.len()) + .unwrap_or(0), + caret_before, + caret_after = focused_input(*focused_element, input_fields) + .map(|input_field| input_field.caret) + .unwrap_or(0), + text_changed = outcome.text_changed, + caret_changed = outcome.caret_changed, + focus_changed = outcome.focus_changed, + selection_changed = outcome.selection_changed, + "processed 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, + caret_blink_token: u64, +) { + if let Some(handle) = caret_blink_timer.take() { + clear_timeout(&handle); + } + let window = window.clone(); + *caret_blink_timer = Some(set_timeout(INPUT_CARET_BLINK_INTERVAL, move || { + let _ = window.emit_wake(caret_blink_token); + })); +} + +fn reset_caret_blink( + window: &WindowController, + focused_element: Option, + input_fields: &[InputFieldState], + caret_visible: &mut bool, + caret_blink_timer: &mut Option, + caret_blink_token: &mut u64, +) { + *caret_blink_token = caret_blink_token.wrapping_add(1); + if let Some(handle) = caret_blink_timer.take() { + clear_timeout(&handle); + } + if focused_input(focused_element, input_fields).is_some() { + *caret_visible = true; + schedule_caret_blink(window, caret_blink_timer, *caret_blink_token); + } else { + *caret_visible = false; + } +} + +fn scene_with_overlays( + base_scene: &SceneSnapshot, + interaction_tree: &InteractionTree, + selection: Option, + focused_element: Option, + input_fields: &[InputFieldState], + caret_visible: bool, + version: u64, +) -> SceneSnapshot { let mut scene = base_scene.clone(); scene.version = version; let mut items = Vec::with_capacity(scene.items.len() + 8); + let selection = selection.filter(|selection| !selection.is_collapsed()); for item in scene.items.drain(..) { - if let DisplayItem::Text(prepared_text) = &item + if let Some(selection) = selection + && let DisplayItem::Text(prepared_text) = &item && prepared_text.element_id == Some(selection.element_id) { for rect in prepared_text.selection_rects(selection.anchor, selection.focus) { @@ -441,6 +1036,16 @@ fn scene_with_selection( } items.push(item); } + if caret_visible + && let Some(input_field) = focused_input(focused_element, input_fields) + && 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) + { + items.push(DisplayItem::Quad(Quad::new( + caret_rect, + Color::rgb(0xF5, 0xF7, 0xFB), + ))); + } scene.items = items; scene } @@ -466,7 +1071,54 @@ fn open_rust_website() { } } -fn build_document_tree(viewport: UiSize, hovered_card: Option) -> Element { +fn input_field_element(input_field: &InputFieldState, focused: bool) -> Element { + let field_background = if focused { + Color::rgb(0x10, 0x1A, 0x2A) + } else { + Color::rgb(0x12, 0x18, 0x24) + }; + let text_element = if !focused && input_field.text.is_empty() { + Element::text( + input_field.placeholder, + TextStyle::new(18.0, Color::rgb(0x7D, 0x89, 0x9E)) + .with_line_height(24.0) + .with_wrap(TextWrap::None) + .with_selectable(false), + ) + } else { + Element::text( + input_field.text.as_str(), + TextStyle::new(18.0, Color::rgb(0xF5, 0xF7, 0xFB)) + .with_line_height(24.0) + .with_wrap(TextWrap::None) + .with_selectable(true), + ) + .id(input_field.text_id) + }; + + Element::column().gap(10.0).children([ + Element::paragraph( + input_field.label, + TextStyle::new(16.0, Color::rgb(0xA7, 0xF3, 0xD0)) + .with_line_height(22.0) + .with_selectable(false), + ), + Element::column() + .id(input_field.field_id) + .focusable(true) + .cursor(CursorIcon::Text) + .padding(Edges::symmetric(14.0, 12.0)) + .background(field_background) + .child(text_element.cursor(CursorIcon::Text)), + ]) +} + +fn build_document_tree( + viewport: UiSize, + hovered_card: Option, + input_fields: &[InputFieldState], + focused_element: Option, +) -> Element { let gutter = (viewport.width * 0.025).clamp(18.0, 30.0); let sidebar_width = (viewport.width * 0.28).clamp(220.0, 320.0); @@ -522,6 +1174,14 @@ fn build_document_tree(viewport: UiSize, hovered_card: Option) -> Ele ) .id(RUST_LINK_ID) .cursor(CursorIcon::Pointer), + input_field_element( + &input_fields[0], + focused_element == Some(input_fields[0].field_id), + ), + input_field_element( + &input_fields[1], + focused_element == Some(input_fields[1].field_id), + ), ]), Element::row().flex(1.0).gap(gutter).children([ Element::column() diff --git a/lib/ui/examples/explicit_ui_prototype.rs b/lib/ui/examples/explicit_ui_prototype.rs index f11d30e..e35b891 100644 --- a/lib/ui/examples/explicit_ui_prototype.rs +++ b/lib/ui/examples/explicit_ui_prototype.rs @@ -79,6 +79,34 @@ fn log_platform_event(event: &PlatformEvent) { "pointer event received" ); } + PlatformEvent::Keyboard { window_id, event } => { + tracing::debug!( + event = "keyboard_event", + window_id = window_id.raw(), + keycode = event.keycode, + ?event.kind, + ?event.key, + ?event.modifiers, + text = event.text.as_deref().unwrap_or(""), + "keyboard event received" + ); + } + PlatformEvent::Wake { window_id, token } => { + tracing::debug!( + event = "wake_event", + window_id = window_id.raw(), + token, + "internal wake event received" + ); + } + PlatformEvent::PrimarySelectionText { window_id, text } => { + tracing::debug!( + event = "primary_selection_text", + window_id = window_id.raw(), + text, + "primary selection text received" + ); + } PlatformEvent::CloseRequested { window_id } => { tracing::info!( event = "close_requested", diff --git a/lib/ui/src/interaction.rs b/lib/ui/src/interaction.rs index 7409dd6..6287ebb 100644 --- a/lib/ui/src/interaction.rs +++ b/lib/ui/src/interaction.rs @@ -4,6 +4,7 @@ use crate::scene::Point; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PointerButton { Primary, + Middle, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -164,6 +165,7 @@ mod tests { element_id: None, rect: Rect::new(0.0, 0.0, 200.0, 120.0), pointer_events: false, + focusable: false, cursor: CursorIcon::Default, prepared_text: None, children: vec![ @@ -172,6 +174,7 @@ mod tests { element_id: Some(ElementId::new(1)), rect: Rect::new(0.0, 0.0, 120.0, 120.0), pointer_events: true, + focusable: false, cursor: CursorIcon::Default, prepared_text: None, children: Vec::new(), @@ -181,6 +184,7 @@ mod tests { element_id: Some(ElementId::new(2)), rect: Rect::new(80.0, 0.0, 120.0, 120.0), pointer_events: true, + focusable: false, cursor: CursorIcon::Default, prepared_text: None, children: Vec::new(), @@ -197,6 +201,7 @@ mod tests { element_id: None, rect: Rect::new(0.0, 0.0, 200.0, 120.0), pointer_events: false, + focusable: false, cursor: CursorIcon::Default, prepared_text: None, children: vec![LayoutNode { @@ -204,6 +209,7 @@ mod tests { element_id: Some(ElementId::new(1)), rect: Rect::new(0.0, 0.0, 160.0, 120.0), pointer_events: true, + focusable: false, cursor: CursorIcon::Default, prepared_text: None, children: vec![LayoutNode { @@ -211,6 +217,7 @@ mod tests { element_id: Some(ElementId::new(2)), rect: Rect::new(16.0, 16.0, 80.0, 40.0), pointer_events: true, + focusable: false, cursor: CursorIcon::Default, prepared_text: None, children: Vec::new(), diff --git a/lib/ui/src/keyboard.rs b/lib/ui/src/keyboard.rs new file mode 100644 index 0000000..7ef0c7e --- /dev/null +++ b/lib/ui/src/keyboard.rs @@ -0,0 +1,57 @@ +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct KeyboardModifiers { + pub shift: bool, + pub control: bool, + pub alt: bool, + pub super_key: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum KeyboardKey { + Character(String), + Enter, + Tab, + Escape, + Backspace, + Delete, + ArrowLeft, + ArrowRight, + ArrowUp, + ArrowDown, + Home, + End, + Unknown, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum KeyboardEventKind { + Pressed, + Released, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KeyboardEvent { + pub keycode: u32, + pub kind: KeyboardEventKind, + pub key: KeyboardKey, + pub modifiers: KeyboardModifiers, + pub text: Option, +} + +impl KeyboardEvent { + pub fn new( + keycode: u32, + kind: KeyboardEventKind, + key: KeyboardKey, + modifiers: KeyboardModifiers, + text: Option, + ) -> Self { + Self { + keycode, + kind, + key, + modifiers, + text, + } + } +} diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index bd5abc8..c730439 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -48,6 +48,7 @@ pub struct HitTarget { pub path: LayoutPath, pub element_id: Option, pub rect: Rect, + pub focusable: bool, pub cursor: CursorIcon, } @@ -57,6 +58,7 @@ pub struct LayoutNode { pub element_id: Option, pub rect: Rect, pub pointer_events: bool, + pub focusable: bool, pub cursor: CursorIcon, pub prepared_text: Option, pub children: Vec, @@ -184,6 +186,7 @@ fn layout_element( element_id: element.id, rect, pointer_events: element.style.pointer_events, + focusable: element.style.focusable, cursor, prepared_text: None, children: Vec::new(), @@ -330,6 +333,7 @@ fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option Option Option Result<(), PlatformClosed> { + self.send(PlatformRequest::RequestPrimarySelectionText { window_id }) + } + pub fn set_cursor_icon( &self, window_id: WindowId, @@ -243,6 +274,18 @@ impl PlatformProxy { self.send(PlatformRequest::EmitPointerEvent { window_id, event }) } + pub fn emit_keyboard_event( + &self, + window_id: WindowId, + event: KeyboardEvent, + ) -> Result<(), PlatformClosed> { + self.send(PlatformRequest::EmitKeyboardEvent { window_id, event }) + } + + pub fn emit_wake(&self, window_id: WindowId, token: u64) -> Result<(), PlatformClosed> { + self.send(PlatformRequest::EmitWake { window_id, token }) + } + pub fn shutdown(&self) -> Result<(), PlatformClosed> { self.send(PlatformRequest::Shutdown) } @@ -286,6 +329,7 @@ pub fn start_headless() -> PlatformRuntime { handle_replace_scene(&state, window_id, scene); } PlatformRequest::SetPrimarySelectionText { .. } => {} + PlatformRequest::RequestPrimarySelectionText { .. } => {} PlatformRequest::SetCursorIcon { .. } => {} PlatformRequest::EmitCloseRequested { window_id } => { let sender = state.borrow().events.clone(); @@ -294,6 +338,14 @@ pub fn start_headless() -> PlatformRuntime { PlatformRequest::EmitPointerEvent { window_id, event } => { handle_emit_pointer_event(&state, window_id, event); } + PlatformRequest::EmitKeyboardEvent { window_id, event } => { + let sender = state.borrow().events.clone(); + let _ = sender.send(PlatformEvent::Keyboard { window_id, event }); + } + PlatformRequest::EmitWake { window_id, token } => { + let sender = state.borrow().events.clone(); + let _ = sender.send(PlatformEvent::Wake { window_id, token }); + } PlatformRequest::Shutdown => { debug!( target: trace_targets::PLATFORM, diff --git a/lib/ui/src/runtime.rs b/lib/ui/src/runtime.rs index 20833bb..2064a86 100644 --- a/lib/ui/src/runtime.rs +++ b/lib/ui/src/runtime.rs @@ -2,6 +2,7 @@ use ruin_reactivity::{EffectHandle, effect}; +use crate::keyboard::KeyboardEvent; use crate::platform::{PlatformClosed, PlatformEvent, PlatformProxy, PlatformRuntime}; use crate::scene::SceneSnapshot; use crate::tree::CursorIcon; @@ -116,6 +117,11 @@ impl WindowController { self.proxy.set_primary_selection_text(self.id, text) } + /// 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) + } + /// Updates the platform cursor icon for this window. pub fn set_cursor_icon(&self, cursor: CursorIcon) -> Result<(), PlatformClosed> { self.proxy.set_cursor_icon(self.id, cursor) @@ -134,6 +140,16 @@ impl WindowController { self.proxy.emit_pointer_event(self.id, event) } + /// Delivers a keyboard event for this window through the platform event stream. + pub fn emit_keyboard_event(&self, event: KeyboardEvent) -> Result<(), PlatformClosed> { + self.proxy.emit_keyboard_event(self.id, event) + } + + /// Delivers an internal wake event for this window through the platform event stream. + pub fn emit_wake(&self, token: u64) -> Result<(), PlatformClosed> { + self.proxy.emit_wake(self.id, token) + } + /// Attaches a reactive effect that rebuilds and replaces the window scene whenever dependent UI /// state changes. pub fn attach_scene_effect( @@ -156,7 +172,10 @@ mod tests { use crate::platform::PlatformEvent; use crate::scene::{Color, Point, PreparedText, Rect, SceneSnapshot, UiSize}; use crate::window::{WindowSpec, WindowUpdate}; - use crate::{PointerEvent, PointerEventKind}; + use crate::{ + KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers, PointerEvent, + PointerEventKind, + }; use ruin_runtime::{current_thread_handle, queue_future, run}; use std::future::Future; @@ -310,4 +329,52 @@ mod tests { ui.shutdown().expect("shutdown should queue"); }); } + + #[test] + fn window_controller_emits_keyboard_events_through_runtime() { + run_async_test(async move { + let mut ui = UiRuntime::headless(); + let window = ui + .create_window(WindowSpec::new("keyboard-events").visible(true)) + .expect("window should be created"); + + let _ = ui + .wait_for_event_matching(|event| { + matches!(event, PlatformEvent::Opened { window_id } if *window_id == window.id()) + }) + .await + .expect("window should open before keyboard delivery"); + + let keyboard_event = KeyboardEvent::new( + 30, + KeyboardEventKind::Pressed, + KeyboardKey::Character("a".to_owned()), + KeyboardModifiers::default(), + Some("a".to_owned()), + ); + window + .emit_keyboard_event(keyboard_event.clone()) + .expect("keyboard event should queue"); + + let event = ui + .wait_for_event_matching(|event| { + matches!( + event, + PlatformEvent::Keyboard { window_id, .. } if *window_id == window.id() + ) + }) + .await + .expect("keyboard event should be delivered"); + + assert_eq!( + event, + PlatformEvent::Keyboard { + window_id: window.id(), + event: keyboard_event, + } + ); + + ui.shutdown().expect("shutdown should queue"); + }); + } } diff --git a/lib/ui/src/scene.rs b/lib/ui/src/scene.rs index 3959ad4..1efa56e 100644 --- a/lib/ui/src/scene.rs +++ b/lib/ui/src/scene.rs @@ -228,6 +228,18 @@ impl PreparedText { rects } + pub fn caret_rect(&self, offset: usize, width: f32) -> Option { + let width = width.max(0.0); + let line = self.line_for_offset(offset)?; + let x = self.caret_x_for_line_offset(line, offset); + Some(Rect::new( + x, + line.rect.origin.y, + width, + line.rect.size.height, + )) + } + pub fn apply_selected_text_color(&mut self, start: usize, end: usize) { let Some(selected_color) = self.selection_style.text_color else { return; @@ -260,6 +272,18 @@ impl PreparedText { Some(last) } + fn line_for_offset(&self, offset: usize) -> Option<&PreparedTextLine> { + let offset = offset.min(self.text.len()); + let mut lines = self.lines.iter(); + let first = lines.next()?; + for line in std::iter::once(first).chain(lines) { + if offset <= line.text_end { + return Some(line); + } + } + Some(self.lines.last().unwrap_or(first)) + } + fn byte_offset_for_line_position(&self, line: &PreparedTextLine, x: f32) -> usize { if line.glyph_start == line.glyph_end { return line.text_start; @@ -285,6 +309,33 @@ impl PreparedText { line.text_end } + + fn caret_x_for_line_offset(&self, line: &PreparedTextLine, offset: usize) -> f32 { + if line.glyph_start == line.glyph_end { + return line.rect.origin.x; + } + + let offset = offset.min(self.text.len()); + let line_glyphs = &self.glyphs[line.glyph_start..line.glyph_end]; + let first_glyph = &line_glyphs[0]; + if offset <= first_glyph.text_start { + return first_glyph.position.x; + } + + for glyph in line_glyphs { + if offset <= glyph.text_start { + return glyph.position.x; + } + if offset <= glyph.text_end { + return glyph.position.x + glyph.advance.max(0.0); + } + } + + line_glyphs + .last() + .map(|glyph| glyph.position.x + glyph.advance.max(0.0)) + .unwrap_or(line.rect.origin.x) + } } #[derive(Clone, Debug, PartialEq)] @@ -409,4 +460,28 @@ mod tests { assert_eq!(text.glyphs[1].color, Color::rgb(0x11, 0x12, 0x1A)); assert_eq!(text.glyphs[2].color, Color::rgb(0x11, 0x12, 0x1A)); } + + #[test] + fn prepared_text_caret_rect_tracks_cluster_boundaries() { + let text = PreparedText::monospace( + "abcd", + Point::new(10.0, 20.0), + 16.0, + 8.0, + Color::rgb(0xFF, 0xFF, 0xFF), + ); + + assert_eq!( + text.caret_rect(0, 2.0), + Some(Rect::new(10.0, 20.0, 2.0, 16.0)) + ); + assert_eq!( + text.caret_rect(2, 2.0), + Some(Rect::new(26.0, 20.0, 2.0, 16.0)) + ); + assert_eq!( + text.caret_rect(4, 2.0), + Some(Rect::new(42.0, 20.0, 2.0, 16.0)) + ); + } } diff --git a/lib/ui/src/tree.rs b/lib/ui/src/tree.rs index e8713e9..474178d 100644 --- a/lib/ui/src/tree.rs +++ b/lib/ui/src/tree.rs @@ -72,6 +72,7 @@ pub struct Style { pub padding: Edges, pub background: Option, pub pointer_events: bool, + pub focusable: bool, pub cursor: Option, } @@ -86,6 +87,7 @@ impl Default for Style { padding: Edges::ZERO, background: None, pointer_events: true, + focusable: false, cursor: None, } } @@ -198,6 +200,11 @@ impl Element { self } + pub fn focusable(mut self, focusable: bool) -> Self { + self.style.focusable = focusable; + self + } + pub fn cursor(mut self, cursor: CursorIcon) -> Self { self.style.cursor = Some(cursor); self diff --git a/lib/ui_platform_wayland/Cargo.toml b/lib/ui_platform_wayland/Cargo.toml index 9f424ba..f94cfe8 100644 --- a/lib/ui_platform_wayland/Cargo.toml +++ b/lib/ui_platform_wayland/Cargo.toml @@ -13,3 +13,4 @@ tracing = "0.1" wayland-backend = { version = "0.3", features = ["client_system"] } wayland-client = "0.31" wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] } +xkbcommon = "0.8" diff --git a/lib/ui_platform_wayland/src/lib.rs b/lib/ui_platform_wayland/src/lib.rs index 90b58e0..1dd0a9e 100644 --- a/lib/ui_platform_wayland/src/lib.rs +++ b/lib/ui_platform_wayland/src/lib.rs @@ -4,12 +4,13 @@ use std::error::Error; use std::ffi::c_void; use std::fs::File; use std::io::ErrorKind; +use std::io::Read; use std::io::Write; use std::num::NonZeroU32; -use std::os::fd::{AsFd, AsRawFd}; +use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; use std::ptr::NonNull; use std::rc::Rc; -use std::time::Duration; +use std::time::{Duration, Instant}; use raw_window_handle::{ DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, @@ -18,15 +19,17 @@ use raw_window_handle::{ use ruin_runtime::channel::mpsc; use ruin_runtime::{WorkerHandle, queue_future, queue_task, spawn_worker}; use ruin_ui::{ - CursorIcon, PlatformEndpoint, PlatformEvent, PlatformRequest, PlatformRuntime, Point, - PointerButton, PointerEvent, PointerEventKind, SceneSnapshot, UiRuntime, UiSize, - WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate, + CursorIcon, KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers, PlatformEndpoint, + PlatformEvent, PlatformRequest, PlatformRuntime, Point, PointerButton, PointerEvent, + PointerEventKind, SceneSnapshot, UiRuntime, UiSize, WindowConfigured, WindowId, + WindowLifecycle, WindowSpec, WindowUpdate, }; use ruin_ui_renderer_wgpu::{RenderError, WgpuSceneRenderer}; +use tracing::Level; use tracing::{debug, trace}; use wayland_client::globals::{GlobalListContents, registry_queue_init}; use wayland_client::protocol::{ - wl_callback, wl_compositor, wl_pointer, wl_registry, wl_seat, wl_surface, + wl_callback, wl_compositor, wl_keyboard, wl_pointer, wl_registry, wl_seat, wl_surface, }; use wayland_client::{ Connection, Dispatch, Proxy, QueueHandle, WEnum, delegate_noop, event_created_child, @@ -39,6 +42,7 @@ use wayland_protocols::wp::primary_selection::zv1::client::{ zwp_primary_selection_offer_v1, zwp_primary_selection_source_v1, }; use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; +use xkbcommon::xkb; #[derive(Clone)] pub struct WaylandSurfaceTarget { @@ -114,6 +118,7 @@ struct WindowWorkerState { enum WindowWorkerCommand { ReplaceScene(SceneSnapshot), SetPrimarySelectionText(String), + RequestPrimarySelectionText, SetCursorIcon(CursorIcon), ApplySpec(WindowSpec), Shutdown, @@ -139,6 +144,11 @@ struct State { primary_selection_device: Option, qh: QueueHandle, pointer: Option, + keyboard: Option, + keyboard_focused: bool, + xkb_context: xkb::Context, + xkb_keymap: Option, + xkb_state: Option, current_size: (u32, u32), configured: bool, pending_size: Option<(u32, u32)>, @@ -146,17 +156,54 @@ struct State { frame_callback: Option, pointer_position: Option, pending_pointer_events: Vec, + pending_keyboard_events: Vec, + keyboard_modifiers: KeyboardModifiers, + keyboard_repeat_rate: i32, + keyboard_repeat_delay: Duration, + keyboard_repeat: Option, primary_selection_source: Option, primary_selection_text: Option, + primary_selection_offer: Option, + primary_selection_offer_mime_types: Vec, last_selection_serial: Option, last_pointer_enter_serial: Option, cursor_icon: CursorIcon, } +#[derive(Clone, Debug)] +struct KeyboardRepeatState { + keycode: u32, + next_at: Instant, + interval: Duration, +} + impl State { fn request_redraw(&mut self) { self.needs_redraw = true; } + + fn queue_ready_keyboard_repeats(&mut self) { + let Some(repeat) = self.keyboard_repeat.as_mut() else { + return; + }; + let Some(xkb_state) = self.xkb_state.as_ref() else { + return; + }; + let now = Instant::now(); + while now >= repeat.next_at { + let keycode = xkb::Keycode::new(repeat.keycode + 8); + let text = keyboard_text_for_xkb(xkb_state, keycode); + let key = keyboard_key_from_xkb(xkb_state.key_get_one_sym(keycode), text.as_deref()); + self.pending_keyboard_events.push(KeyboardEvent::new( + repeat.keycode, + KeyboardEventKind::Pressed, + key, + self.keyboard_modifiers, + text, + )); + repeat.next_at += repeat.interval; + } + } } fn wayland_cursor_shape(icon: CursorIcon) -> wp_cursor_shape_device_v1::Shape { @@ -178,6 +225,48 @@ fn apply_cursor_icon(state: &mut State) { let _ = state._connection.flush(); } +fn keyboard_modifiers_from_xkb(state: &xkb::State) -> KeyboardModifiers { + KeyboardModifiers { + shift: state.mod_name_is_active(xkb::MOD_NAME_SHIFT, xkb::STATE_MODS_EFFECTIVE), + control: state.mod_name_is_active(xkb::MOD_NAME_CTRL, xkb::STATE_MODS_EFFECTIVE), + alt: state.mod_name_is_active(xkb::MOD_NAME_ALT, xkb::STATE_MODS_EFFECTIVE), + super_key: state.mod_name_is_active(xkb::MOD_NAME_LOGO, xkb::STATE_MODS_EFFECTIVE), + } +} + +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) { + return None; + } + Some(text) +} + +fn keyboard_key_from_xkb(keysym: xkb::Keysym, text: Option<&str>) -> KeyboardKey { + match xkb::keysym_get_name(keysym).as_str() { + "BackSpace" => KeyboardKey::Backspace, + "Delete" => KeyboardKey::Delete, + "Return" => KeyboardKey::Enter, + "Tab" => KeyboardKey::Tab, + "Escape" => KeyboardKey::Escape, + "Left" => KeyboardKey::ArrowLeft, + "Right" => KeyboardKey::ArrowRight, + "Up" => KeyboardKey::ArrowUp, + "Down" => KeyboardKey::ArrowDown, + "Home" => KeyboardKey::Home, + "End" => KeyboardKey::End, + _ => text + .filter(|text| !text.is_empty()) + .map(str::to_owned) + .or_else(|| { + let utf8 = xkb::keysym_to_utf8(keysym); + (!utf8.is_empty() && !utf8.chars().any(char::is_control)).then_some(utf8) + }) + .map(KeyboardKey::Character) + .unwrap_or(KeyboardKey::Unknown), + } +} + impl WaylandWindow { pub fn open(spec: WindowSpec) -> Result> { let connection = Connection::connect_to_env()?; @@ -241,6 +330,11 @@ impl WaylandWindow { primary_selection_device, qh, pointer: None, + keyboard: None, + keyboard_focused: false, + xkb_context: xkb::Context::new(xkb::CONTEXT_NO_FLAGS), + xkb_keymap: None, + xkb_state: None, current_size: (initial_width, initial_height), configured: false, pending_size: None, @@ -248,8 +342,15 @@ impl WaylandWindow { frame_callback: None, pointer_position: None, pending_pointer_events: Vec::new(), + pending_keyboard_events: Vec::new(), + keyboard_modifiers: KeyboardModifiers::default(), + keyboard_repeat_rate: 25, + keyboard_repeat_delay: Duration::from_millis(500), + keyboard_repeat: None, primary_selection_source: None, primary_selection_text: None, + primary_selection_offer: None, + primary_selection_offer_mime_types: Vec::new(), last_selection_serial: None, last_pointer_enter_serial: None, cursor_icon: CursorIcon::Default, @@ -430,6 +531,43 @@ impl WaylandWindow { Ok(()) } + 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 Some(mime_type) = preferred_mime else { + return Ok(self.state.primary_selection_text.clone()); + }; + let Some(offer) = self.state.primary_selection_offer.as_ref() else { + return Ok(self.state.primary_selection_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_cursor_icon(&mut self, cursor: CursorIcon) -> Result<(), Box> { self.state.cursor_icon = cursor; apply_cursor_icon(&mut self.state); @@ -476,6 +614,11 @@ impl WaylandWindow { std::mem::take(&mut self.state.pending_pointer_events) } + pub fn drain_keyboard_events(&mut self) -> Vec { + self.state.queue_ready_keyboard_repeats(); + std::mem::take(&mut self.state.pending_keyboard_events) + } + pub fn prepare_frame(&mut self) -> Option { if !self.state.configured { return None; @@ -537,6 +680,9 @@ fn run_wayland_platform(mut endpoint: PlatformEndpoint) { PlatformRequest::SetPrimarySelectionText { window_id, text } => { handle_set_primary_selection_text(&state, window_id, text); } + PlatformRequest::RequestPrimarySelectionText { window_id } => { + handle_request_primary_selection_text(&state, window_id); + } PlatformRequest::SetCursorIcon { window_id, cursor } => { handle_set_cursor_icon(&state, window_id, cursor); } @@ -546,6 +692,12 @@ fn run_wayland_platform(mut endpoint: PlatformEndpoint) { PlatformRequest::EmitPointerEvent { window_id, event } => { emit_wayland_event(&state, PlatformEvent::Pointer { window_id, event }); } + PlatformRequest::EmitKeyboardEvent { window_id, event } => { + emit_wayland_event(&state, PlatformEvent::Keyboard { window_id, event }); + } + PlatformRequest::EmitWake { window_id, token } => { + emit_wayland_event(&state, PlatformEvent::Wake { window_id, token }); + } PlatformRequest::Shutdown => { shutdown_wayland_backend(&state); break; @@ -702,6 +854,21 @@ fn handle_set_primary_selection_text( } } +fn handle_request_primary_selection_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::RequestPrimarySelectionText); + } +} + fn handle_set_cursor_icon( state: &Rc>, window_id: WindowId, @@ -818,6 +985,28 @@ fn spawn_window_worker( ); } } + WindowWorkerCommand::RequestPrimarySelectionText => { + let mut state_ref = state.borrow_mut(); + match state_ref.window.read_primary_selection_text() { + Ok(Some(text)) => { + let _ = state_ref.event_tx.send( + PlatformEvent::PrimarySelectionText { + 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 primary selection text" + ); + } + } + } WindowWorkerCommand::SetCursorIcon(cursor) => { let mut state_ref = state.borrow_mut(); if let Err(error) = state_ref.window.set_cursor_icon(cursor) { @@ -877,6 +1066,21 @@ fn pump_window_worker(state: Rc>) { event, }); } + for event in state_ref.window.drain_keyboard_events() { + tracing::trace!( + target: "ruin_ui_platform_wayland::event_bridge", + window_id = state_ref.window_id.raw(), + keycode = event.keycode, + ?event.kind, + ?event.key, + text = event.text.as_deref().unwrap_or(""), + "forwarding keyboard event to UI runtime" + ); + let _ = state_ref.event_tx.send(PlatformEvent::Keyboard { + window_id: state_ref.window_id, + event, + }); + } if !state_ref.window.is_running() { emit_window_closed(&mut state_ref, true); @@ -1086,8 +1290,6 @@ delegate_noop!( State: ignore zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1 ); -delegate_noop!(State: ignore zwp_primary_selection_offer_v1::ZwpPrimarySelectionOfferV1); - impl Dispatch for State { fn event( state: &mut Self, @@ -1116,6 +1318,17 @@ impl Dispatch for State { state.pointer_position = None; state.last_pointer_enter_serial = None; } + if capabilities.contains(wl_seat::Capability::Keyboard) { + if state.keyboard.is_none() { + state.keyboard = Some(seat.get_keyboard(qh, ())); + } + } else { + state.keyboard = None; + state.xkb_keymap = None; + state.xkb_state = None; + state.keyboard_modifiers = KeyboardModifiers::default(); + state.keyboard_repeat = None; + } } } } @@ -1178,17 +1391,21 @@ impl Dispatch for State { let Some(position) = state.pointer_position else { return; }; - if button != 0x110 { - return; + let button = match button { + 0x110 => PointerButton::Primary, + 0x112 => PointerButton::Middle, + _ => return, + }; + if button == PointerButton::Primary { + state.last_selection_serial = Some(serial); } - state.last_selection_serial = Some(serial); let kind = match button_state { - WEnum::Value(wl_pointer::ButtonState::Pressed) => PointerEventKind::Down { - button: PointerButton::Primary, - }, - WEnum::Value(wl_pointer::ButtonState::Released) => PointerEventKind::Up { - button: PointerButton::Primary, - }, + WEnum::Value(wl_pointer::ButtonState::Pressed) => { + PointerEventKind::Down { button } + } + WEnum::Value(wl_pointer::ButtonState::Released) => { + PointerEventKind::Up { button } + } WEnum::Value(_) | WEnum::Unknown(_) => return, }; state @@ -1200,15 +1417,200 @@ impl Dispatch for State { } } -impl Dispatch for State { +impl Dispatch for State { fn event( - _state: &mut Self, - _data_device: &zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1, - _event: zwp_primary_selection_device_v1::Event, + state: &mut Self, + _keyboard: &wl_keyboard::WlKeyboard, + event: wl_keyboard::Event, _data: &(), _conn: &Connection, _qh: &QueueHandle, ) { + tracing::event!( + target: "ruin_ui_platform_wayland::keyboard", + Level::INFO, + ?event, + "received keyboard event" + ); + match event { + wl_keyboard::Event::Keymap { format, fd, size } => { + let WEnum::Value(wl_keyboard::KeymapFormat::XkbV1) = format else { + tracing::info!( + target: "ruin_ui_platform_wayland::keyboard", + ?format, + "ignored unsupported keymap format" + ); + return; + }; + let Ok(size) = usize::try_from(size) else { + tracing::info!( + target: "ruin_ui_platform_wayland::keyboard", + size, + "ignored keymap with invalid size" + ); + return; + }; + let keymap = match unsafe { + xkb::Keymap::new_from_fd( + &state.xkb_context, + fd, + size, + xkb::KEYMAP_FORMAT_TEXT_V1, + xkb::KEYMAP_COMPILE_NO_FLAGS, + ) + } { + Ok(Some(keymap)) => keymap, + Ok(None) => { + tracing::info!( + target: "ruin_ui_platform_wayland::keyboard", + "failed to compile XKB keymap" + ); + return; + } + Err(error) => { + tracing::info!( + target: "ruin_ui_platform_wayland::keyboard", + size, + error = %error, + "failed to map compositor keymap fd" + ); + return; + } + }; + state.xkb_state = Some(xkb::State::new(&keymap)); + state.xkb_keymap = Some(keymap); + state.keyboard_modifiers = KeyboardModifiers::default(); + state.keyboard_repeat = None; + tracing::info!( + target: "ruin_ui_platform_wayland::keyboard", + "installed XKB keymap" + ); + } + wl_keyboard::Event::Enter { .. } => { + state.keyboard_focused = true; + state.keyboard_repeat = None; + } + wl_keyboard::Event::Leave { .. } => { + state.keyboard_focused = false; + state.keyboard_modifiers = KeyboardModifiers::default(); + state.keyboard_repeat = None; + } + wl_keyboard::Event::RepeatInfo { rate, delay } => { + state.keyboard_repeat_rate = rate; + state.keyboard_repeat_delay = Duration::from_millis(delay.max(0) as u64); + if rate <= 0 { + state.keyboard_repeat = None; + } + } + wl_keyboard::Event::Modifiers { + mods_depressed, + mods_latched, + mods_locked, + group, + .. + } => { + let Some(xkb_state) = state.xkb_state.as_mut() else { + return; + }; + xkb_state.update_mask(mods_depressed, mods_latched, mods_locked, 0, 0, group); + state.keyboard_modifiers = keyboard_modifiers_from_xkb(xkb_state); + } + wl_keyboard::Event::Key { + key, + state: key_state, + .. + } => { + if !state.keyboard_focused { + tracing::info!( + target: "ruin_ui_platform_wayland::keyboard", + key, + ?key_state, + "dropping key because keyboard focus is not active" + ); + return; + } + let Some(xkb_state) = state.xkb_state.as_mut() else { + tracing::info!( + target: "ruin_ui_platform_wayland::keyboard", + key, + ?key_state, + "dropping key because XKB state is not initialized" + ); + return; + }; + let (kind, direction) = match key_state { + WEnum::Value(wl_keyboard::KeyState::Pressed) => { + (KeyboardEventKind::Pressed, xkb::KeyDirection::Down) + } + WEnum::Value(wl_keyboard::KeyState::Released) => { + (KeyboardEventKind::Released, xkb::KeyDirection::Up) + } + WEnum::Value(_) | WEnum::Unknown(_) => return, + }; + let keycode = xkb::Keycode::new(key + 8); + xkb_state.update_key(keycode, direction); + state.keyboard_modifiers = keyboard_modifiers_from_xkb(xkb_state); + let text = matches!(kind, KeyboardEventKind::Pressed) + .then(|| keyboard_text_for_xkb(xkb_state, keycode)) + .flatten(); + let logical_key = + keyboard_key_from_xkb(xkb_state.key_get_one_sym(keycode), text.as_deref()); + state.pending_keyboard_events.push(KeyboardEvent::new( + key, + kind, + logical_key.clone(), + state.keyboard_modifiers, + text, + )); + tracing::info!( + target: "ruin_ui_platform_wayland::keyboard", + keycode = key, + ?kind, + ?logical_key, + modifiers = ?state.keyboard_modifiers, + queued = state.pending_keyboard_events.len(), + "queued translated keyboard event" + ); + if kind == KeyboardEventKind::Pressed + && state.keyboard_repeat_rate > 0 + && state + .xkb_keymap + .as_ref() + .is_some_and(|keymap| keymap.key_repeats(keycode)) + { + state.keyboard_repeat = Some(KeyboardRepeatState { + keycode: key, + next_at: Instant::now() + state.keyboard_repeat_delay, + interval: Duration::from_secs_f64( + 1.0 / f64::from(state.keyboard_repeat_rate), + ), + }); + } else if state + .keyboard_repeat + .as_ref() + .is_some_and(|repeat| repeat.keycode == key) + { + state.keyboard_repeat = None; + } + } + _ => {} + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _data_device: &zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1, + event: zwp_primary_selection_device_v1::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + if let zwp_primary_selection_device_v1::Event::Selection { id } = event { + state.primary_selection_offer = id; + state.primary_selection_offer_mime_types.clear(); + } } event_created_child!(State, zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1, [ @@ -1217,6 +1619,23 @@ impl Dispatch ]); } +impl Dispatch for State { + fn event( + state: &mut Self, + offer: &zwp_primary_selection_offer_v1::ZwpPrimarySelectionOfferV1, + event: zwp_primary_selection_offer_v1::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + if let zwp_primary_selection_offer_v1::Event::Offer { mime_type } = event + && state.primary_selection_offer.as_ref() == Some(offer) + { + state.primary_selection_offer_mime_types.push(mime_type); + } + } +} + impl Dispatch for State { fn event( state: &mut Self,