// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! `ltk::run` in a browser. //! //! A page's main thread must not block, so `run` sets the app up and returns, and the browser drives the loop: `AppData` lives in a closure a thread-local [`Loop`] holds, which a `setTimeout` runs for messages, timers and page events and a `requestAnimationFrame` runs to draw. Page events and `ChannelSender::send` only queue and schedule, never touching `AppData`, so nothing they set off can run inside an iteration. use std::cell::{ Cell, RefCell }; use std::collections::VecDeque; use std::rc::Rc; use std::time::Duration; use serde_json::json; use wasm_bindgen::closure::Closure; use wasm_bindgen::{ JsCast, JsValue }; use web_sys::{ Document, Event, EventTarget, HtmlCanvasElement, HtmlElement, OffscreenCanvas, WebGl2RenderingContext }; use crate::app::{ App, ToplevelEvent }; use crate::event_loop::{ AppData, RunError, Sources, SurfaceFocus, SurfaceState }; use super::desktop::{ main_role, Connection, MAIN }; use super::input::WebInput; use super::{ WebPlatform, WebSurfaceState }; /// What the page reports between iterations. pub( super ) enum PageEvent { Resized, Input( WebInput ), ContextLost, ContextRestored, /// forge/web's size and scale for a surface. Configure { surface: u32, width: f64, height: f64, scale: f64, }, /// forge/web asks to close a surface. Close { surface: u32, }, /// A window of the desktop's, as `ext-foreign-toplevel-list` reports it. Toplevel( ToplevelEvent ), /// A directory another client changed. FilesChanged( std::path::PathBuf ), Mounts( Vec ), /// The files the app was launched to open. OpenFiles( Vec ), /// The desktop's clipboard, or with `primary` what the middle button pastes, as an application of the page set it. Selection { primary: bool, text: String, }, } /// An event listener, registered for as long as it lives. pub( crate ) struct Listener { target: EventTarget, kind: &'static str, closure: Closure, } impl Listener { pub( super ) fn new( target: &EventTarget, kind: &'static str, handle: impl FnMut( Event ) + 'static ) -> Result { let closure = Closure::::new( handle ); target.add_event_listener_with_callback( kind, closure.as_ref().unchecked_ref() ).map_err( |_| RunError::NoDocument )?; Ok( Self { target: target.clone(), kind, closure } ) } } impl Drop for Listener { fn drop( &mut self ) { let _ = self.target.remove_event_listener_with_callback( self.kind, self.closure.as_ref().unchecked_ref() ); } } /// What an iteration asks of the next. struct Next { frame: bool, after: Option, exit: bool, } struct Loop { iterate: RefCell Next>>, /// The pending timeout and when it fires, in milliseconds since the epoch. timeout: Cell>, frame: Cell>, ended: Cell, on_timeout: Closure, on_frame: Closure, } thread_local! { static LOOP: RefCell>> = const { RefCell::new( None ) }; static EVENTS: RefCell> = const { RefCell::new( VecDeque::new() ) }; static CANVAS: RefCell> = const { RefCell::new( None ) }; } /// Draw the app on `canvas` rather than on one ltk lays over the whole page. Call it before `ltk::run`. pub fn set_canvas( canvas: HtmlCanvasElement ) { CANVAS.with( |c| *c.borrow_mut() = Some( canvas ) ); } pub( crate ) fn run( app: A ) { if let Err( e ) = try_run( app ) { panic!( "ltk::run failed during init: {e}" ); } } /// Set the app up on the page and return; the browser runs it from then on. pub( crate ) fn try_run( app: A ) -> Result<(), RunError> { super::log::install_panic_hook(); let window = web_sys::window().ok_or( RunError::NoDocument )?; let document = window.document().ok_or( RunError::NoDocument )?; let offscreen = webgl2(); if offscreen.is_none() { web_sys::console::warn_1( &"[ltk] no WebGL2 context: drawing in software".into() ); } crate::render::set_software_render( offscreen.is_none() ); // A canvas the page named wins; else the page's desktop gives the main surface, and without one ltk lays a canvas over the page. let given = CANVAS.with( |c| c.borrow_mut().take() ); let desktop = if given.is_none() { Connection::open( app.app_id() ) } else { None }; if desktop.is_none() { super::files::connected( None ); } let ( canvas, target, own ) = match ( desktop.as_ref(), given ) { ( Some( desktop ), _ ) => { let ( canvas, input ) = desktop.create_surface( MAIN, main_role( &app ) ).map_err( RunError::Desktop )?; if app.start_fullscreen() { desktop.set_fullscreen( MAIN, true ); } ( canvas, input, false ) } ( None, Some( canvas ) ) => ( canvas.clone(), HtmlElement::from( canvas ), false ), ( None, None ) => { let canvas = page_canvas( &document )?; ( canvas.clone(), HtmlElement::from( canvas ), true ) } }; if desktop.is_none() { target.set_tab_index( 0 ); let style = target.style(); let _ = style.set_property( "touch-action", "none" ); let _ = style.set_property( "outline", "none" ); } let input = super::input::listen( &target, desktop.is_none(), SurfaceFocus::Main )?; let mut listeners = Vec::new(); if desktop.is_none() { listeners.push( Listener::new( &window, "resize", |_| push( PageEvent::Resized ) )? ); } if let Some( ( offscreen, _ ) ) = offscreen.as_ref() { // Without `preventDefault` the browser never gives the context back. listeners.push( Listener::new( offscreen, "webglcontextlost", |event| { event.prevent_default(); push( PageEvent::ContextLost ); } )? ); listeners.push( Listener::new( offscreen, "webglcontextrestored", |_| push( PageEvent::ContextRestored ) )? ); } let inset_probe = if own { Some( inset_probe( &document )? ) } else { None }; if own { let _ = target.focus(); } let coarse = window.match_media( "(pointer: coarse)" ).ok().flatten().is_some_and( |m| m.matches() ); let main = SurfaceState::new( WebSurfaceState::new( canvas, target.clone(), MAIN, offscreen.is_some(), input ), 0.0, String::new() ); let platform = WebPlatform::new( desktop, offscreen, target, listeners, inset_probe ); // ltk's own keyboard where the page is used by touch, since the page cannot raise the system's for a canvas. let mut data = AppData::new( app, platform, main, Sources::new( wake ), coarse, None ); data.install_sources()?; // In a desktop the size comes with forge/web's first configure. if data.platform.desktop.is_none() { data.fit_canvas_element(); } let mut data = Some( data ); let iterate: Box Next> = Box::new( move |in_frame| { let Some( app ) = data.as_mut() else { return Next { frame: false, after: None, exit: true } }; let next = app.iterate( in_frame ); if next.exit { data = None; } next } ); let lp = Loop { iterate: RefCell::new( iterate ), timeout: Cell::new( None ), frame: Cell::new( None ), ended: Cell::new( false ), on_timeout: Closure::new( || run_iteration( false ) ), on_frame: Closure::new( |_: f64| run_iteration( true ) ), }; LOOP.with( |l| *l.borrow_mut() = Some( Rc::new( lp ) ) ); wake(); Ok( () ) } /// Ask for an iteration as soon as the page is idle. pub( super ) fn wake() { with_loop( |lp| lp.schedule_in( 0.0 ) ); } pub( super ) fn push( event: PageEvent ) { EVENTS.with( |q| q.borrow_mut().push_back( event ) ); wake(); } fn with_loop( f: impl FnOnce( &Loop ) ) { if let Some( lp ) = LOOP.with( |l| l.borrow().clone() ) { f( &lp ); } } fn run_iteration( in_frame: bool ) { with_loop( |lp| { if in_frame { lp.frame.set( None ); } else { lp.timeout.set( None ); } if lp.ended.get() { return; } let Ok( mut iterate ) = lp.iterate.try_borrow_mut() else { return }; let next = ( *iterate )( in_frame ); drop( iterate ); if next.exit { lp.end(); return; } if next.frame { lp.request_frame(); } if let Some( after ) = next.after { lp.schedule_in( after.as_secs_f64() * 1000.0 ); } } ); } impl Loop { /// Run an iteration in `ms` milliseconds, unless one is already due sooner. fn schedule_in( &self, ms: f64 ) { if self.ended.get() { return; } let Some( window ) = web_sys::window() else { return }; let at = js_sys::Date::now() + ms; if let Some( ( id, due ) ) = self.timeout.get() { if due <= at { return; } window.clear_timeout_with_handle( id ); } let delay = ms.ceil().clamp( 0.0, i32::MAX as f64 ) as i32; if let Ok( id ) = window.set_timeout_with_callback_and_timeout_and_arguments_0( self.on_timeout.as_ref().unchecked_ref(), delay ) { self.timeout.set( Some( ( id, at ) ) ); } } fn request_frame( &self ) { if self.frame.get().is_some() { return; } let Some( window ) = web_sys::window() else { return }; if let Ok( id ) = window.request_animation_frame( self.on_frame.as_ref().unchecked_ref() ) { self.frame.set( Some( id ) ); } } /// Run no more iterations. The closures stay alive, since one of them is running. fn end( &self ) { self.ended.set( true ); let Some( window ) = web_sys::window() else { return }; if let Some( ( id, _ ) ) = self.timeout.take() { window.clear_timeout_with_handle( id ); } if let Some( id ) = self.frame.take() { let _ = window.cancel_animation_frame( id ); } } } impl AppData { /// What the page reported, the due timers and messages, then the runtime's steps; drawing only in an animation frame. fn iterate( &mut self, in_frame: bool ) -> Next { while let Some( event ) = EVENTS.with( |q| q.borrow_mut().pop_front() ) { match event { PageEvent::Resized => self.fit_canvas_element(), PageEvent::Input( input ) => self.web_input( input ), PageEvent::ContextLost => self.webgl_context_lost(), PageEvent::ContextRestored => self.webgl_context_restored(), PageEvent::Configure { surface: MAIN, width, height, scale } => self.configure_main( width, height, scale ), PageEvent::Configure { surface, width, height, scale } => self.configure_overlay( surface, width, height, scale ), PageEvent::Close { surface: MAIN } => { self.request_close(); } PageEvent::Close { surface } => self.dismiss_overlay( surface ), PageEvent::Toplevel( event ) => { if let Some( msg ) = self.app.on_toplevel_event( event ) { self.pending_msgs.push( msg ); } } PageEvent::FilesChanged( dir ) => { if let Some( msg ) = self.app.on_files_changed( &dir ) { self.pending_msgs.push( msg ); } } PageEvent::Mounts( mounts ) => { crate::files::set_mounts( &mounts ); if let Some( msg ) = self.app.on_mounts_changed( &mounts ) { self.pending_msgs.push( msg ); } } PageEvent::OpenFiles( paths ) => { if let Some( msg ) = self.app.on_open_files( paths ) { self.pending_msgs.push( msg ); } } PageEvent::Selection { primary: true, text } => self.primary_selection = text, PageEvent::Selection { primary: false, text } => self.clipboard = text, } } self.dispatch_sources(); if in_frame { for focus in self.platform.frame_due.take() { let hosts = super::subsurfaces::hosts_subsurfaces( self, focus ); self.frame_presented( focus, hosts ); } } self.before_draw(); super::desktop::issue_activation_tokens( self ); super::overlays::reconcile_overlays( self ); let drawn = in_frame && self.draw_due(); super::subsurfaces::reconcile_subsurfaces( self ); super::subsurfaces::keep_cadence( self ); self.after_draw( drawn ); if self.exit_requested { self.leave_page(); return Next { frame: false, after: None, exit: true }; } let due = |ss: &SurfaceState| ss.configured && ss.needs_redraw && !ss.frame_pending; let frame = due( &self.main ) || self.overlays.values().any( due ) || !self.platform.frame_due.borrow().is_empty(); let now = crate::time::Instant::now(); let after = [ self.next_wakeup(), self.sources.next_due().map( |due| due.saturating_duration_since( now ) ) ] .into_iter() .flatten() .min(); Next { frame, after, exit: false } } fn dispatch_sources( &mut self ) { for ( id, mut tick ) in self.sources.take_due( crate::time::Instant::now() ) { let next = tick( self ); self.sources.rearm( id, tick, next ); } let mut drains = self.sources.take_channels(); for drain in &mut drains { drain( self ); } self.sources.restore_channels( drains ); } /// Stop listening to the page, give the desktop its surfaces back, and tell the page, with an `ltk-exit` event on the canvas, that the app has ended. fn leave_page( &mut self ) { self.platform.listeners.clear(); self.main.platform.listeners.clear(); self.overlays.clear(); self.platform.subsurfaces.clear(); if let Some( probe ) = self.platform.inset_probe.take() { probe.remove(); } if let Some( desktop ) = self.platform.desktop.as_ref() { let _ = desktop.request( &json!( { "type": "destroy_surface", "surface": MAIN } ) ); desktop.close(); } if let Ok( event ) = Event::new( "ltk-exit" ) { let _ = self.main.platform.canvas.dispatch_event( &event ); } } } /// A canvas over the whole page, for an app that named none. fn page_canvas( document: &Document ) -> Result { let canvas: HtmlCanvasElement = document.create_element( "canvas" ).ok() .and_then( |e| e.dyn_into().ok() ) .ok_or( RunError::NoDocument )?; let _ = canvas.set_attribute( "style", "position: fixed; inset: 0; width: 100%; height: 100%; display: block; touch-action: none" ); document.body().ok_or( RunError::NoDocument )?.append_child( &canvas ).map_err( |_| RunError::NoDocument )?; Ok( canvas ) } /// An invisible element padded by the page's safe-area insets, read back as numbers by `fit_canvas_element`. fn inset_probe( document: &Document ) -> Result { let probe = document.create_element( "div" ).map_err( |_| RunError::NoDocument )?; let _ = probe.set_attribute( "style", "position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; \ padding: env( safe-area-inset-top ) env( safe-area-inset-right ) env( safe-area-inset-bottom ) env( safe-area-inset-left )" ); document.body().ok_or( RunError::NoDocument )?.append_child( &probe ).map_err( |_| RunError::NoDocument )?; Ok( probe ) } /// The app's one WebGL2 context, on a canvas of its own off the page, which every surface is painted through: premultiplied alpha as ltk writes it, and no depth, stencil or multisampling, which it does not use. fn webgl2() -> Option<( OffscreenCanvas, WebGl2RenderingContext )> { let offscreen = OffscreenCanvas::new( 1, 1 ).ok()?; let options = js_sys::Object::new(); for ( key, value ) in [ ( "alpha", true ), ( "premultipliedAlpha", true ), ( "antialias", false ), ( "depth", false ), ( "stencil", false ) ] { js_sys::Reflect::set( &options, &JsValue::from_str( key ), &JsValue::from_bool( value ) ).ok()?; } let context = offscreen.get_context_with_context_options( "webgl2", &options ).ok()??.dyn_into().ok()?; Some( ( offscreen, context ) ) }