Keyboard input, text input elements
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
57
lib/ui/src/keyboard.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user