// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! The runtime driven on [`MockPlatform`]: input through the backend-neutral entry points, a step of the run loop, and what reached the platform. use std::sync::MutexGuard; use calloop::EventLoop; use crate::app::App; use crate::input::event::{ KeyInput, PointerButton, PointerInput, PointerKind }; use crate::osk::Purpose; use crate::platform::mock::{ MockPlatform, Request }; use crate::types::Rect; use crate::widget::WidgetHandlers; use crate::{ button, column, text_edit, Element, Keysym }; use super::{ AppData, Sources, SurfaceFocus, SurfaceState }; use super::surface::Insets; const WIDTH: u32 = 400; const HEIGHT: u32 = 300; #[ derive( Clone, Debug, PartialEq ) ] enum Msg { Pressed, Typed( String ), } #[ derive( Default ) ] struct TestApp { value: String, secure: bool, purpose: Purpose, presses: u32, } impl App for TestApp { type Message = Msg; fn app_id( &self ) -> &str { "net.liberux.ltk.Test" } fn save_state( &self ) -> Option> { None } fn restore_state( &mut self, _state: Vec ) {} fn view( &self ) -> Element { column() .push( button( "OK" ).on_press( Msg::Pressed ) ) .push( text_edit( "", self.value.clone() ).secure( self.secure ).purpose( self.purpose ).on_change( Msg::Typed ) ) .into() } fn update( &mut self, msg: Msg ) { match msg { Msg::Pressed => self.presses += 1, Msg::Typed( text ) => self.value = text, } } } /// Field order matters: `data` holds a handle into `event_loop` and drops first. struct Harness { data: AppData, _event_loop: EventLoop<'static, AppData>, _globals: MutexGuard<'static, ()>, } impl Harness { /// `app` on a configured `WIDTH × HEIGHT` main surface at scale 1, not drawn yet. fn new( app: TestApp ) -> Self { let globals = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); let event_loop: EventLoop<'static, AppData> = EventLoop::try_new().expect( "event loop" ); let main = SurfaceState::new( (), 0.0, String::new() ); let mut data = AppData::new( app, MockPlatform::default(), main, Sources::new( event_loop.handle() ), false, None ); data.main.set_size( WIDTH, HEIGHT ); data.on_main_resized( WIDTH, HEIGHT ); Self { data, _event_loop: event_loop, _globals: globals } } /// One iteration of the run loop's backend-neutral part. Returns what reached the platform since the last step. fn step( &mut self ) -> Vec { self.data.process_messages(); self.data.draw_due(); self.data.platform.take_requests() } /// What the backend does when the system says the last frame is on screen. fn frame_done( &mut self ) { self.data.main.frame_pending = false; } fn rect_of( &self, pick: impl Fn( &WidgetHandlers ) -> bool ) -> Rect { self.data.main.frame.widget_rects.iter() .find( |w| pick( &w.handlers ) ) .map( |w| w.rect ) .expect( "widget laid out" ) } fn button_rect( &self ) -> Rect { self.rect_of( |h| matches!( h, WidgetHandlers::Button { .. } ) ) } fn field_rect( &self ) -> Rect { self.rect_of( |h| h.is_text_input() ) } fn pointer( &mut self, kind: PointerKind, r: Rect ) { let position = ( ( r.x + r.width / 2.0 ) as f64, ( r.y + r.height / 2.0 ) as f64 ); self.data.pointer_input( &PointerInput { focus: SurfaceFocus::Main, position, kind } ); } fn hover( &mut self, r: Rect ) { self.pointer( PointerKind::Enter, r ); self.pointer( PointerKind::Motion, r ); } fn click( &mut self, r: Rect ) { self.hover( r ); self.pointer( PointerKind::Press( PointerButton::Left ), r ); self.pointer( PointerKind::Release( PointerButton::Left ), r ); } fn key( &mut self, keysym: Keysym, text: &str ) { let key = KeyInput::synthetic( keysym, Some( text.to_string() ) ); self.data.key_press( key.clone() ); self.data.key_release( &key ); } } /// The damage of every presentation among `requests`. fn presented( requests: &[Request] ) -> Vec> { requests.iter().filter_map( |r| match r { Request::Present { damage, .. } => Some( damage.clone() ), _ => None, } ).collect() } #[ test ] fn the_first_frame_is_presented_whole_and_asks_for_the_next() { let mut h = Harness::new( TestApp::default() ); let requests = h.step(); assert!( requests.contains( &Request::Present { frame: Some( SurfaceFocus::Main ), damage: Vec::new(), size: ( WIDTH, HEIGHT ) } ) ); assert!( h.data.main.frame_pending ); assert!( !h.data.main.content_dirty ); } #[ test ] fn nothing_is_drawn_while_a_frame_is_pending_or_when_nothing_changed() { let mut h = Harness::new( TestApp::default() ); h.step(); let button = h.button_rect(); h.hover( button ); assert!( presented( &h.step() ).is_empty() ); h.frame_done(); assert_eq!( presented( &h.step() ).len(), 1 ); h.frame_done(); assert!( presented( &h.step() ).is_empty() ); } #[ test ] fn a_click_on_a_button_reaches_update() { let mut h = Harness::new( TestApp::default() ); h.step(); let button = h.button_rect(); h.click( button ); h.step(); assert_eq!( h.data.app.presses, 1 ); } #[ test ] fn hovering_a_widget_presents_only_its_rect() { let mut h = Harness::new( TestApp::default() ); h.step(); h.frame_done(); let button = h.button_rect(); h.hover( button ); let damage = presented( &h.step() ); assert_eq!( damage.len(), 1 ); let rects = &damage[ 0 ]; assert!( !rects.is_empty(), "a hover should repaint part of the surface, not all of it" ); let area: f32 = rects.iter().map( |r| r.width * r.height ).sum(); assert!( area < ( WIDTH * HEIGHT ) as f32 / 2.0 ); assert!( rects.iter().all( |r| r.x >= 0.0 && r.y >= 0.0 && r.x + r.width <= WIDTH as f32 && r.y + r.height <= HEIGHT as f32 ) ); } #[ test ] fn a_failed_presentation_leaves_the_next_frame_to_repaint_whole() { let mut h = Harness::new( TestApp::default() ); h.data.platform.fail_present = true; assert!( presented( &h.step() ).is_empty() ); assert!( !h.data.main.frame_pending ); assert!( h.data.main.content_dirty ); h.data.platform.fail_present = false; let button = h.button_rect(); h.hover( button ); assert_eq!( presented( &h.step() ), vec![ Vec::new() ] ); } #[ test ] fn a_secure_field_asks_the_input_method_for_a_password() { let mut h = Harness::new( TestApp { secure: true, ..TestApp::default() } ); h.step(); let field = h.field_rect(); h.click( field ); assert!( h.step().contains( &Request::TextInput { secure: true, purpose: Purpose::Normal } ) ); } #[ test ] fn a_field_hands_its_purpose_to_the_input_method() { let mut h = Harness::new( TestApp { purpose: Purpose::Terminal, ..TestApp::default() } ); h.step(); let field = h.field_rect(); h.click( field ); assert!( h.step().contains( &Request::TextInput { secure: false, purpose: Purpose::Terminal } ) ); } #[ test ] fn the_view_is_laid_out_inside_the_insets() { let mut h = Harness::new( TestApp::default() ); h.data.main.insets = Insets { top: 40.0, right: 0.0, bottom: 30.0, left: 10.0 }; h.step(); let button = h.button_rect(); assert!( button.y >= 40.0 && button.x >= 10.0, "button at {button:?}" ); } #[ test ] fn copying_a_selection_publishes_the_clipboard() { let mut h = Harness::new( TestApp { value: "hola".to_string(), ..TestApp::default() } ); h.step(); let field = h.field_rect(); h.click( field ); h.data.modifiers_changed( true, false, false ); h.key( Keysym::a, "a" ); h.key( Keysym::c, "c" ); assert!( h.step().contains( &Request::Clipboard( "hola".to_string() ) ) ); assert_eq!( h.data.clipboard, "hola" ); }