diff --git a/internal/backends/testing/introspection/mod.rs b/internal/backends/testing/introspection/mod.rs index fb331ce2490..634084e6ca0 100644 --- a/internal/backends/testing/introspection/mod.rs +++ b/internal/backends/testing/introspection/mod.rs @@ -10,7 +10,7 @@ use i_slint_core::window::WindowAdapter; use i_slint_core::window::WindowInner; use slotmap::{Key, KeyData, SlotMap}; use std::cell::{Cell, RefCell}; -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use std::rc::{Rc, Weak}; use crate::{ElementHandle, ElementRoot, LayoutKind}; @@ -24,10 +24,12 @@ pub(crate) mod proto; /// Maximum number of element handles kept in the arena before evicting the oldest. const ELEMENT_HANDLE_CAP: usize = 10_000; +const EVENT_LOG_CAP: usize = 1024; thread_local! { static SHARED_STATE: RefCell>> = const { RefCell::new(None) }; - static HOOK_INSTALLED: Cell = const { Cell::new(false) }; + static WINDOW_TRACKING_HOOK_INSTALLED: Cell = const { Cell::new(false) }; + static EVENT_TRACKING_HOOK_INSTALLED: Cell = const { Cell::new(false) }; } /// Returns the shared introspection state, creating it if needed. @@ -47,9 +49,9 @@ pub(crate) fn shared_state() -> Rc { /// Safe to call multiple times — only installs once. /// Chains with any previously installed hook. pub(crate) fn ensure_window_tracking() -> Result<(), i_slint_core::api::EventLoopError> { - HOOK_INSTALLED.with(|installed| { + WINDOW_TRACKING_HOOK_INSTALLED.with(|installed| { if installed.get() { - return Ok(()); + return ensure_event_tracking(); } installed.set(true); @@ -67,6 +69,31 @@ pub(crate) fn ensure_window_tracking() -> Result<(), i_slint_core::api::EventLoo }))) .map_err(|_| i_slint_core::api::EventLoopError::NoEventLoopProvider)?; + ensure_event_tracking() + }) +} + +fn ensure_event_tracking() -> Result<(), i_slint_core::api::EventLoopError> { + EVENT_TRACKING_HOOK_INSTALLED.with(|installed| { + if installed.get() { + return Ok(()); + } + installed.set(true); + + let state = shared_state(); + let previous_hook = i_slint_core::context::set_window_event_hook(None) + .map_err(|_| i_slint_core::api::EventLoopError::NoEventLoopProvider)?; + + i_slint_core::context::set_window_event_hook(Some(Box::new( + move |adapter, event, result| { + if let Some(prev) = previous_hook.as_ref() { + prev(adapter, event, result); + } + state.record_window_event(adapter, event, result); + }, + ))) + .map_err(|_| i_slint_core::api::EventLoopError::NoEventLoopProvider)?; + Ok(()) }) } @@ -92,6 +119,10 @@ pub(crate) struct IntrospectionState { pub windows: RefCell>, pub element_handles: RefCell>, element_handle_order: RefCell>, + event_log: RefCell>, + next_event_sequence: Cell, + dropped_event_count: Cell, + recording_enabled: Cell, } impl IntrospectionState { @@ -100,6 +131,10 @@ impl IntrospectionState { windows: Default::default(), element_handles: Default::default(), element_handle_order: Default::default(), + event_log: Default::default(), + next_event_sequence: Default::default(), + dropped_event_count: Default::default(), + recording_enabled: Cell::new(false), } } @@ -119,6 +154,16 @@ impl IntrospectionState { self.windows.borrow().iter().map(|(index, _)| index).collect() } + fn window_handle_for_adapter(&self, adapter: &Rc) -> Option { + self.windows.borrow().iter().find_map(|(index, tracked)| { + tracked + .window_adapter + .upgrade() + .filter(|tracked_adapter| Rc::ptr_eq(tracked_adapter, adapter)) + .map(|_| index) + }) + } + pub fn window_adapter( &self, window_index: ArenaIndex, @@ -147,7 +192,7 @@ impl IntrospectionState { let mut order = self.element_handle_order.borrow_mut(); order.push_back(index); if arena.len() > ELEMENT_HANDLE_CAP { - let root_indices: std::collections::HashSet = + let root_indices: HashSet = self.windows.borrow().iter().map(|(_, w)| w.root_element_handle).collect(); let mut budget = order.len(); while arena.len() > ELEMENT_HANDLE_CAP && budget > 0 { @@ -237,6 +282,101 @@ impl IntrospectionState { Ok(()) } + pub fn record_window_event( + &self, + adapter: &Rc, + event: &i_slint_core::platform::WindowEvent, + result: i_slint_core::context::WindowEventDispatchResult, + ) { + if !self.recording_enabled.get() { + return; + } + let sequence = self.next_event_sequence.get(); + self.next_event_sequence.set(sequence.saturating_add(1)); + + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or_default(); + + let mut log = self.event_log.borrow_mut(); + if log.len() >= EVENT_LOG_CAP { + log.pop_front(); + self.dropped_event_count.set(self.dropped_event_count.get().saturating_add(1)); + } + log.push_back(proto::RecordedEvent { + sequence, + timestamp_ms, + window_handle: self.window_handle_for_adapter(adapter).map(index_to_handle), + source: proto::RecordedEventSource::Runtime.into(), + event: Some(convert_window_event_to_proto(event)), + result: convert_event_dispatch_result(result).into(), + }); + } + + #[cfg(feature = "system-testing")] + pub fn query_event_log( + &self, + window_index: Option, + since_sequence: u64, + max_events: u32, + clear_after_read: bool, + ) -> proto::EventLogResponse { + let max_events = if max_events == 0 { 200 } else { max_events.min(1000) } as usize; + let events: Vec<_> = self + .event_log + .borrow() + .iter() + .filter(|event| event.sequence >= since_sequence) + .filter(|event| { + window_index.is_none_or(|window_index| { + event.window_handle.as_ref().is_some_and(|handle| { + handle_to_index(*handle) + .is_ok_and(|event_window| event_window == window_index) + }) + }) + }) + .take(max_events) + .cloned() + .collect(); + let next_sequence = events + .last() + .map(|event| event.sequence.saturating_add(1)) + .unwrap_or_else(|| self.next_event_sequence.get()); + let returned_sequences: HashSet = events.iter().map(|event| event.sequence).collect(); + let response = proto::EventLogResponse { + events, + // Pass the next unread sequence number directly so callers can use + // it as sinceSequence on the next poll without arithmetic. + next_sequence, + dropped_count: self.dropped_event_count.get(), + }; + if clear_after_read { + self.event_log + .borrow_mut() + .retain(|event| !returned_sequences.contains(&event.sequence)); + } + response + } + + pub fn clear_event_log(&self) { + self.event_log.borrow_mut().clear(); + self.dropped_event_count.set(0); + } + + pub fn start_recording(&self) { + self.clear_event_log(); + self.recording_enabled.set(true); + } + + pub fn stop_recording(&self) -> proto::StopEventRecordingResponse { + self.recording_enabled.set(false); + let events: Vec<_> = self.event_log.borrow().iter().cloned().collect(); + let dropped_count = self.dropped_event_count.get(); + self.clear_event_log(); + proto::StopEventRecordingResponse { events, dropped_count } + } + pub fn window_properties( &self, window_index: ArenaIndex, @@ -269,6 +409,97 @@ impl IntrospectionState { } } +pub(crate) fn convert_window_event_to_proto( + event: &i_slint_core::platform::WindowEvent, +) -> proto::WindowEvent { + use i_slint_core::platform::WindowEvent; + use proto::window_event::Event; + + let event = match event { + WindowEvent::PointerPressed { position, button } => { + Some(Event::PointerPressed(proto::PointerPressEvent { + position: Some(proto::LogicalPosition { x: position.x, y: position.y }), + button: convert_pointer_event_button_to_proto(*button).into(), + })) + } + WindowEvent::PointerReleased { position, button } => { + Some(Event::PointerReleased(proto::PointerReleaseEvent { + position: Some(proto::LogicalPosition { x: position.x, y: position.y }), + button: convert_pointer_event_button_to_proto(*button).into(), + })) + } + WindowEvent::PointerMoved { position } => { + Some(Event::PointerMoved(proto::PointerMoveEvent { + position: Some(proto::LogicalPosition { x: position.x, y: position.y }), + })) + } + WindowEvent::PointerScrolled { position, delta_x, delta_y } => { + Some(Event::PointerScrolled(proto::PointerScrolledEvent { + position: Some(proto::LogicalPosition { x: position.x, y: position.y }), + delta_x: *delta_x, + delta_y: *delta_y, + })) + } + WindowEvent::PointerExited => Some(Event::PointerExited(proto::PointerExitedEvent {})), + WindowEvent::KeyPressed { text } => { + Some(Event::KeyPressed(proto::KeyPressedEvent { text: text.to_string() })) + } + WindowEvent::KeyPressRepeated { text } => { + Some(Event::KeyPressRepeated(proto::KeyPressRepeatedEvent { text: text.to_string() })) + } + WindowEvent::KeyReleased { text } => { + Some(Event::KeyReleased(proto::KeyReleasedEvent { text: text.to_string() })) + } + WindowEvent::ScaleFactorChanged { scale_factor } => { + Some(Event::ScaleFactorChanged(proto::ScaleFactorChangedEvent { + scale_factor: *scale_factor, + })) + } + WindowEvent::Resized { size } => Some(Event::Resized(proto::ResizedEvent { + size: Some(proto::LogicalSize { width: size.width, height: size.height }), + })), + WindowEvent::CloseRequested => Some(Event::CloseRequested(proto::CloseRequestedEvent {})), + WindowEvent::WindowActiveChanged(active) => { + Some(Event::WindowActiveChanged(proto::WindowActiveChangedEvent { active: *active })) + } + // All current variants are covered above. This arm exists only because + // WindowEvent is #[non_exhaustive]; future variants will log with event: None. + #[allow(unreachable_patterns)] + _ => None, + }; + + proto::WindowEvent { event } +} + +fn convert_pointer_event_button_to_proto( + button: i_slint_core::platform::PointerEventButton, +) -> proto::PointerEventButton { + match button { + i_slint_core::platform::PointerEventButton::Left => proto::PointerEventButton::Left, + i_slint_core::platform::PointerEventButton::Right => proto::PointerEventButton::Right, + i_slint_core::platform::PointerEventButton::Middle => proto::PointerEventButton::Middle, + i_slint_core::platform::PointerEventButton::Back => proto::PointerEventButton::Back, + i_slint_core::platform::PointerEventButton::Forward => proto::PointerEventButton::Forward, + i_slint_core::platform::PointerEventButton::Other => proto::PointerEventButton::Other, + // PointerEventButton is #[non_exhaustive]; future buttons map to Other. + #[allow(unreachable_patterns)] + _ => proto::PointerEventButton::Other, + } +} + +fn convert_event_dispatch_result( + result: i_slint_core::context::WindowEventDispatchResult, +) -> proto::RecordedEventResult { + match result { + i_slint_core::context::WindowEventDispatchResult::Processed => { + proto::RecordedEventResult::Processed + } + i_slint_core::context::WindowEventDispatchResult::Ignored => { + proto::RecordedEventResult::Ignored + } + } +} + // ============================================================================ // Shared proto ↔ core conversion functions // ============================================================================ @@ -440,6 +671,9 @@ pub(crate) fn convert_pointer_event_button( proto::PointerEventButton::Left => i_slint_core::platform::PointerEventButton::Left, proto::PointerEventButton::Right => i_slint_core::platform::PointerEventButton::Right, proto::PointerEventButton::Middle => i_slint_core::platform::PointerEventButton::Middle, + proto::PointerEventButton::Back => i_slint_core::platform::PointerEventButton::Back, + proto::PointerEventButton::Forward => i_slint_core::platform::PointerEventButton::Forward, + proto::PointerEventButton::Other => i_slint_core::platform::PointerEventButton::Other, } } @@ -537,6 +771,36 @@ pub(crate) mod dispatch { state.take_snapshot_response(window, image_mime_type) } + #[cfg(feature = "system-testing")] + pub(crate) fn event_log( + state: &IntrospectionState, + window: Option, + since_sequence: u64, + max_events: u32, + clear_after_read: bool, + ) -> proto::EventLogResponse { + state.query_event_log(window, since_sequence, max_events, clear_after_read) + } + + #[cfg(feature = "system-testing")] + pub(crate) fn clear_event_log(state: &IntrospectionState) -> proto::ClearEventLogResponse { + state.clear_event_log(); + proto::ClearEventLogResponse {} + } + + pub(crate) fn start_event_recording( + state: &IntrospectionState, + ) -> proto::StartEventRecordingResponse { + state.start_recording(); + proto::StartEventRecordingResponse {} + } + + pub(crate) fn stop_event_recording( + state: &IntrospectionState, + ) -> proto::StopEventRecordingResponse { + state.stop_recording() + } + pub(crate) fn invoke_accessibility_action( state: &IntrospectionState, element: ArenaIndex, @@ -635,6 +899,151 @@ fn test_handle_to_index_rejects_out_of_range_parts() { ); } +#[test] +fn test_event_log_filters_since_sequence_and_window() { + let state = IntrospectionState::new(); + let mut window_indices = SlotMap::with_key(); + let first_window = window_indices.insert(()); + let second_window = window_indices.insert(()); + + state.next_event_sequence.set(3); + state.event_log.borrow_mut().extend([ + proto::RecordedEvent { + sequence: 0, + window_handle: Some(index_to_handle(first_window)), + source: proto::RecordedEventSource::Runtime.into(), + result: proto::RecordedEventResult::Processed.into(), + ..Default::default() + }, + proto::RecordedEvent { + sequence: 1, + window_handle: Some(index_to_handle(second_window)), + source: proto::RecordedEventSource::Runtime.into(), + result: proto::RecordedEventResult::Processed.into(), + ..Default::default() + }, + proto::RecordedEvent { + sequence: 2, + window_handle: Some(index_to_handle(first_window)), + source: proto::RecordedEventSource::Runtime.into(), + result: proto::RecordedEventResult::Ignored.into(), + ..Default::default() + }, + ]); + + let response = state.query_event_log(Some(first_window), 1, 10, true); + assert_eq!(response.events.len(), 1); + assert_eq!(response.events[0].sequence, 2); + assert_eq!(response.next_sequence, 3); + // clear_after_read removes only returned events. + let remaining = state.query_event_log(None, 0, 10, false); + assert_eq!(remaining.events.iter().map(|event| event.sequence).collect::>(), vec![0, 1]); + assert_eq!(remaining.next_sequence, 2); +} + +#[test] +fn test_event_log_eviction_at_cap() { + let state = IntrospectionState::new(); + + // Fill the log to capacity directly, simulating EVENT_LOG_CAP + 10 recorded events. + for seq in 0..(EVENT_LOG_CAP + 10) as u64 { + let mut log = state.event_log.borrow_mut(); + if log.len() >= EVENT_LOG_CAP { + log.pop_front(); + state.dropped_event_count.set(state.dropped_event_count.get().saturating_add(1)); + } + log.push_back(proto::RecordedEvent { + sequence: seq, + source: proto::RecordedEventSource::Runtime.into(), + result: proto::RecordedEventResult::Processed.into(), + ..Default::default() + }); + state.next_event_sequence.set(seq + 1); + } + + assert_eq!(state.event_log.borrow().len(), EVENT_LOG_CAP); + assert_eq!(state.dropped_event_count.get(), 10); + + // The oldest retained event should have sequence 10 (the first 10 were evicted). + let response = state.query_event_log(None, 0, 1, false); + assert_eq!(response.events[0].sequence, 10); + assert_eq!(response.dropped_count, 10); + assert_eq!(response.next_sequence, 11); + + // After clear, dropped count and log reset, but the sequence cursor remains monotonic. + state.clear_event_log(); + assert!(state.event_log.borrow().is_empty()); + assert_eq!(state.dropped_event_count.get(), 0); + assert_eq!(state.next_event_sequence.get(), (EVENT_LOG_CAP + 10) as u64); + assert_eq!(state.query_event_log(None, 0, 1, false).next_sequence, (EVENT_LOG_CAP + 10) as u64); +} + +#[test] +fn test_event_log_pagination_cursor_advances_to_returned_page() { + let state = IntrospectionState::new(); + for seq in 0..3 { + state.event_log.borrow_mut().push_back(proto::RecordedEvent { + sequence: seq, + source: proto::RecordedEventSource::Runtime.into(), + result: proto::RecordedEventResult::Processed.into(), + ..Default::default() + }); + } + state.next_event_sequence.set(3); + + let first_page = state.query_event_log(None, 0, 1, false); + assert_eq!(first_page.events[0].sequence, 0); + assert_eq!(first_page.next_sequence, 1); + + let second_page = state.query_event_log(None, first_page.next_sequence, 1, false); + assert_eq!(second_page.events[0].sequence, 1); + assert_eq!(second_page.next_sequence, 2); +} + +#[test] +fn test_event_log_clear_keeps_sequence_monotonic() { + let state = IntrospectionState::new(); + state.next_event_sequence.set(42); + state.event_log.borrow_mut().push_back(proto::RecordedEvent { + sequence: 41, + source: proto::RecordedEventSource::Runtime.into(), + result: proto::RecordedEventResult::Processed.into(), + ..Default::default() + }); + + state.clear_event_log(); + assert_eq!(state.next_event_sequence.get(), 42); + assert_eq!(state.query_event_log(None, 42, 10, false).next_sequence, 42); +} + +#[test] +fn test_pointer_event_button_mapping_preserves_extended_buttons() { + assert_eq!( + convert_pointer_event_button_to_proto(i_slint_core::platform::PointerEventButton::Back), + proto::PointerEventButton::Back + ); + assert_eq!( + convert_pointer_event_button_to_proto(i_slint_core::platform::PointerEventButton::Forward), + proto::PointerEventButton::Forward + ); + assert_eq!( + convert_pointer_event_button_to_proto(i_slint_core::platform::PointerEventButton::Other), + proto::PointerEventButton::Other + ); + assert_eq!( + convert_pointer_event_button(proto::PointerEventButton::Back), + i_slint_core::platform::PointerEventButton::Back + ); + assert_eq!( + convert_pointer_event_button(proto::PointerEventButton::Forward), + i_slint_core::platform::PointerEventButton::Forward + ); + assert_eq!( + convert_pointer_event_button(proto::PointerEventButton::Other), + i_slint_core::platform::PointerEventButton::Other + ); +} + #[test] fn test_accessibility_role_mapping_complete() { macro_rules! test_accessibility_enum_mapping_inner { diff --git a/internal/backends/testing/mcp_server.rs b/internal/backends/testing/mcp_server.rs index 06ddf70ba96..680e8622c20 100644 --- a/internal/backends/testing/mcp_server.rs +++ b/internal/backends/testing/mcp_server.rs @@ -114,6 +114,18 @@ const TOOLS: &[ToolDef] = &[ request_type: "RequestDispatchKeyEvent", optional_fields: &["eventType"], }, + ToolDef { + name: "start_event_recording", + description: "Clear the event log and begin recording window/input events. Call this before the interaction you want to observe, then call stop_event_recording when done.", + request_type: "RequestStartEventRecording", + optional_fields: &[], + }, + ToolDef { + name: "stop_event_recording", + description: "Stop recording and return all events collected since the last start_event_recording call. The response includes an events array and droppedCount (events evicted when the 1024-entry cap was reached). Use to verify that Slint received and processed pointer, key, resize, scale, close, and active-state events.", + request_type: "RequestStopEventRecording", + optional_fields: &[], + }, ]; fn tool_definitions() -> Value { @@ -366,6 +378,18 @@ async fn handle_tool_call( serde_json::to_value(response).map_err(|e| format!("serialize error: {e}"))?, )) } + "start_event_recording" => { + let response = dispatch::start_event_recording(state); + Ok(ToolResult::Json( + serde_json::to_value(response).map_err(|e| format!("serialize error: {e}"))?, + )) + } + "stop_event_recording" => { + let response = dispatch::stop_event_recording(state); + Ok(ToolResult::Json( + serde_json::to_value(response).map_err(|e| format!("serialize error: {e}"))?, + )) + } _ => Err(format!("Unknown tool: {name}")), } } @@ -432,7 +456,8 @@ async fn handle_mcp_request(state: &IntrospectionState, body: &str) -> Option Option { + let window_index = window_handle.map(handle_to_index).transpose()?; + Resp::EventLogResponse(dispatch::event_log( + &self.state, + window_index, + since_sequence, + max_events, + clear_after_read, + )) + } + Req::RequestClearEventLog(..) => { + Resp::ClearEventLogResponse(dispatch::clear_event_log(&self.state)) + } + Req::RequestStartEventRecording(..) => { + Resp::StartEventRecordingResponse(dispatch::start_event_recording(&self.state)) + } + Req::RequestStopEventRecording(..) => { + Resp::StopEventRecordingResponse(dispatch::stop_event_recording(&self.state)) + } // MCP-only tools — not supported over the binary system-testing transport Req::RequestDispatchKeyEvent(..) | Req::RequestGetElementTree(..) => { return Err("this request is only supported via the MCP transport".into()); @@ -199,6 +223,7 @@ impl TestingClient { pub fn init() -> Result<(), EventLoopError> { introspection::ensure_window_tracking()?; let state = introspection::shared_state(); + state.start_recording(); let Some(client) = TestingClient::new(state) else { return Ok(()); @@ -342,5 +367,23 @@ fn convert_window_event( Event::KeyReleased(proto::KeyReleasedEvent { text }) => { i_slint_core::platform::WindowEvent::KeyReleased { text: text.into() } } + Event::ScaleFactorChanged(proto::ScaleFactorChangedEvent { scale_factor }) => { + i_slint_core::platform::WindowEvent::ScaleFactorChanged { scale_factor } + } + Event::Resized(proto::ResizedEvent { size }) => { + i_slint_core::platform::WindowEvent::Resized { + size: { + let size = + size.ok_or_else(|| "Missing logical size in resize event".to_string())?; + i_slint_core::api::LogicalSize { width: size.width, height: size.height } + }, + } + } + Event::CloseRequested(proto::CloseRequestedEvent {}) => { + i_slint_core::platform::WindowEvent::CloseRequested + } + Event::WindowActiveChanged(proto::WindowActiveChangedEvent { active }) => { + i_slint_core::platform::WindowEvent::WindowActiveChanged(active) + } }) } diff --git a/internal/core/api.rs b/internal/core/api.rs index 9505d5431f5..f440821e107 100644 --- a/internal/core/api.rs +++ b/internal/core/api.rs @@ -7,6 +7,7 @@ This module contains types that are public and re-exported in the slint-rs as we #![warn(missing_docs)] +use crate::context::WindowEventDispatchResult; use crate::input::{InternalKeyEvent, KeyEventType, MouseEvent, TouchPhase}; use crate::window::{WindowAdapter, WindowInner}; use alloc::boxed::Box; @@ -23,6 +24,15 @@ pub use crate::input::Keys; pub use crate::sharedvector::SharedVector; pub use crate::{format, string::SharedString, string::ToSharedString}; +impl From for WindowEventDispatchResult { + fn from(value: crate::input::KeyEventResult) -> Self { + match value { + crate::input::KeyEventResult::EventAccepted => Self::Processed, + crate::input::KeyEventResult::EventIgnored => Self::Ignored, + } + } +} + /// A position represented in the coordinate space of logical pixels. That is the space before applying /// a display device specific scale factor. #[derive(Debug, Default, Copy, Clone, PartialEq)] @@ -643,7 +653,10 @@ impl Window { &self, event: crate::platform::WindowEvent, ) -> Result<(), PlatformError> { - match event { + // Only clone the event when a hook is installed to avoid allocation on the hot path. + let event_for_hook = + self.0.context().0.window_event_hook.borrow().is_some().then(|| event.clone()); + let dispatch_result = match event { crate::platform::WindowEvent::PointerPressed { position, button } => { self.0.process_mouse_input(MouseEvent::Pressed { position: position.to_euclid().cast(), @@ -651,6 +664,7 @@ impl Window { click_count: 0, touch_finger_id: 0, }); + WindowEventDispatchResult::Processed } crate::platform::WindowEvent::PointerReleased { position, button } => { self.0.process_mouse_input(MouseEvent::Released { @@ -659,12 +673,14 @@ impl Window { click_count: 0, touch_finger_id: 0, }); + WindowEventDispatchResult::Processed } crate::platform::WindowEvent::PointerMoved { position } => { self.0.process_mouse_input(MouseEvent::Moved { position: position.to_euclid().cast(), touch_finger_id: 0, }); + WindowEventDispatchResult::Processed } crate::platform::WindowEvent::PointerScrolled { position, delta_x, delta_y } => { self.0.process_mouse_input(MouseEvent::Wheel { @@ -673,34 +689,40 @@ impl Window { delta_y: delta_y as _, phase: TouchPhase::Cancelled, }); + WindowEventDispatchResult::Processed } crate::platform::WindowEvent::PointerExited => { - self.0.process_mouse_input(MouseEvent::Exit) + self.0.process_mouse_input(MouseEvent::Exit); + WindowEventDispatchResult::Processed } - crate::platform::WindowEvent::KeyPressed { text } => { - self.0.process_key_input(InternalKeyEvent { + crate::platform::WindowEvent::KeyPressed { text } => self + .0 + .process_key_input(InternalKeyEvent { event_type: KeyEventType::KeyPressed, key_event: crate::input::KeyEvent { text, ..Default::default() }, ..Default::default() - }); - } - crate::platform::WindowEvent::KeyPressRepeated { text } => { - self.0.process_key_input(InternalKeyEvent { + }) + .into(), + crate::platform::WindowEvent::KeyPressRepeated { text } => self + .0 + .process_key_input(InternalKeyEvent { event_type: KeyEventType::KeyPressed, key_event: crate::input::KeyEvent { text, repeat: true, ..Default::default() }, ..Default::default() - }); - } - crate::platform::WindowEvent::KeyReleased { text } => { - self.0.process_key_input(InternalKeyEvent { + }) + .into(), + crate::platform::WindowEvent::KeyReleased { text } => self + .0 + .process_key_input(InternalKeyEvent { event_type: KeyEventType::KeyReleased, key_event: crate::input::KeyEvent { text, ..Default::default() }, ..Default::default() - }); - } + }) + .into(), crate::platform::WindowEvent::ScaleFactorChanged { scale_factor } => { self.0.set_scale_factor(scale_factor); + WindowEventDispatchResult::Processed } crate::platform::WindowEvent::Resized { size } => { self.0.set_window_item_geometry(size.to_euclid()); @@ -708,14 +730,26 @@ impl Window { if let Some(item_rc) = self.0.focus_item.borrow().upgrade() { item_rc.try_scroll_into_visible(); } + WindowEventDispatchResult::Processed } crate::platform::WindowEvent::CloseRequested => { if self.0.request_close() { self.hide()?; + WindowEventDispatchResult::Processed + } else { + WindowEventDispatchResult::Ignored } } - crate::platform::WindowEvent::WindowActiveChanged(bool) => self.0.set_active(bool), + crate::platform::WindowEvent::WindowActiveChanged(bool) => { + self.0.set_active(bool); + WindowEventDispatchResult::Processed + } }; + if let Some(event_for_hook) = event_for_hook + && let Some(hook) = self.0.context().0.window_event_hook.borrow().as_ref() + { + hook(&self.0.window_adapter(), &event_for_hook, dispatch_result); + } Ok(()) } diff --git a/internal/core/context.rs b/internal/core/context.rs index b062fedcb90..c1511c43671 100644 --- a/internal/core/context.rs +++ b/internal/core/context.rs @@ -7,12 +7,24 @@ use crate::graphics::Color; use crate::input::InternalKeyboardModifierState; use crate::item_tree::{ItemRc, ItemTreeRc}; use crate::items::ColorScheme; -use crate::platform::{EventLoopProxy, Platform}; +use crate::platform::{EventLoopProxy, Platform, WindowAdapter, WindowEvent}; use alloc::boxed::Box; use alloc::rc::Rc; use core::cell::Cell; use pin_weak::rc::PinWeak; +pub(crate) type WindowEventHook = + Box, &WindowEvent, WindowEventDispatchResult)>; + +/// Result of dispatching a window event through Slint's runtime. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WindowEventDispatchResult { + /// The event was processed by the runtime. + Processed, + /// The event was ignored by the item tree. + Ignored, +} + crate::thread_local! { pub(crate) static GLOBAL_CONTEXT : once_cell::unsync::OnceCell = const { once_cell::unsync::OnceCell::new() } @@ -47,6 +59,7 @@ pub(crate) struct SlintContextInner { pub(crate) accent_color: Property, pub(crate) window_shown_hook: core::cell::RefCell)>>>, + pub(crate) window_event_hook: core::cell::RefCell>, #[cfg(all(unix, not(target_os = "macos")))] xdg_app_id: core::cell::RefCell>, #[cfg(feature = "shared-parley")] @@ -84,7 +97,7 @@ impl SlintContext { color_scheme: Property::new_named(ColorScheme::Unknown, "SlintContext::color_scheme"), accent_color: Property::new_named(Color::default(), "SlintContext::accent_color"), window_shown_hook: Default::default(), - + window_event_hook: Default::default(), #[cfg(all(unix, not(target_os = "macos")))] xdg_app_id: Default::default(), #[cfg(feature = "shared-parley")] @@ -291,3 +304,19 @@ pub fn set_window_shown_hook( None => Err(PlatformError::NoPlatform), }) } + +/// Internal function to set a hook that's invoked after a window event was dispatched. +/// This is used by the system testing module. Returns a previously set hook, if any. +pub fn set_window_event_hook( + hook: Option, +) -> Result, PlatformError> { + GLOBAL_CONTEXT.with(|p| match p.get() { + Some(ctx) => { + let mut slot = ctx.0.window_event_hook.try_borrow_mut().map_err(|_| { + PlatformError::Other(alloc::string::String::from("event hook is currently in use")) + })?; + Ok(core::mem::replace(&mut *slot, hook)) + } + None => Err(PlatformError::NoPlatform), + }) +}