Keyboard input, text input elements

This commit is contained in:
2026-03-21 01:17:07 -04:00
parent 423df4ae1f
commit 84077b718f
13 changed files with 1451 additions and 52 deletions

View File

@@ -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",

View File

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

57
lib/ui/src/keyboard.rs Normal file
View File

@@ -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<String>,
}
impl KeyboardEvent {
pub fn new(
keycode: u32,
kind: KeyboardEventKind,
key: KeyboardKey,
modifiers: KeyboardModifiers,
text: Option<String>,
) -> Self {
Self {
keycode,
kind,
key,
modifiers,
text,
}
}
}

View File

@@ -48,6 +48,7 @@ pub struct HitTarget {
pub path: LayoutPath,
pub element_id: Option<ElementId>,
pub rect: Rect,
pub focusable: bool,
pub cursor: CursorIcon,
}
@@ -57,6 +58,7 @@ pub struct LayoutNode {
pub element_id: Option<ElementId>,
pub rect: Rect,
pub pointer_events: bool,
pub focusable: bool,
pub cursor: CursorIcon,
pub prepared_text: Option<PreparedText>,
pub children: Vec<LayoutNode>,
@@ -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<Vec<Hi
path: node.path.clone(),
element_id: node.element_id,
rect: node.rect,
focusable: node.focusable,
cursor: node.cursor,
});
}
@@ -342,6 +346,7 @@ fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option<Vec<Hi
path: node.path.clone(),
element_id: node.element_id,
rect: node.rect,
focusable: node.focusable,
cursor: node.cursor,
}]);
}
@@ -373,6 +378,7 @@ fn text_hit_test_node(node: &LayoutNode, point: crate::scene::Point) -> Option<T
path: node.path.clone(),
element_id: node.element_id,
rect: node.rect,
focusable: node.focusable,
cursor: node.cursor,
},
byte_offset: prepared_text.byte_offset_for_position(point),

View File

@@ -11,6 +11,7 @@ pub(crate) mod trace_targets {
}
mod interaction;
mod keyboard;
mod layout;
mod platform;
mod runtime;
@@ -23,6 +24,7 @@ pub use interaction::{
PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEvent,
RoutedPointerEventKind,
};
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
pub use layout::{
HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, TextHitTarget,
layout_snapshot, layout_snapshot_with_text_system,

View File

@@ -12,6 +12,7 @@ use ruin_runtime::{WorkerHandle, queue_future, queue_microtask, spawn_worker};
use tracing::{debug, info};
use crate::interaction::PointerEvent;
use crate::keyboard::KeyboardEvent;
use crate::scene::{SceneSnapshot, UiSize};
use crate::trace_targets;
use crate::tree::CursorIcon;
@@ -64,6 +65,18 @@ pub enum PlatformEvent {
window_id: WindowId,
event: PointerEvent,
},
Keyboard {
window_id: WindowId,
event: KeyboardEvent,
},
PrimarySelectionText {
window_id: WindowId,
text: String,
},
Wake {
window_id: WindowId,
token: u64,
},
CloseRequested {
window_id: WindowId,
},
@@ -98,6 +111,9 @@ pub enum PlatformRequest {
window_id: WindowId,
text: String,
},
RequestPrimarySelectionText {
window_id: WindowId,
},
SetCursorIcon {
window_id: WindowId,
cursor: CursorIcon,
@@ -109,6 +125,14 @@ pub enum PlatformRequest {
window_id: WindowId,
event: PointerEvent,
},
EmitKeyboardEvent {
window_id: WindowId,
event: KeyboardEvent,
},
EmitWake {
window_id: WindowId,
token: u64,
},
Shutdown,
}
@@ -223,6 +247,13 @@ impl PlatformProxy {
})
}
pub fn request_primary_selection_text(
&self,
window_id: WindowId,
) -> 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,

View File

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

View File

@@ -228,6 +228,18 @@ impl PreparedText {
rects
}
pub fn caret_rect(&self, offset: usize, width: f32) -> Option<Rect> {
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))
);
}
}

View File

@@ -72,6 +72,7 @@ pub struct Style {
pub padding: Edges,
pub background: Option<Color>,
pub pointer_events: bool,
pub focusable: bool,
pub cursor: Option<CursorIcon>,
}
@@ -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