A browser app joins the page's desktop: on a page with forge/web its main surface, overlays and subsurfaces are surfaces of that desktop, all painted through one WebGL context
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

The browser backend ran an app alone on a canvas over the page. An Eydos desktop in a browser needs crustace as its shell and other ltk apps as its windows on one page, managed by forge/web (forge's web/ directory), which publishes `globalThis.forgeWeb` and stacks, places and focuses the surfaces its clients ask for (forge/web/DESIGN.md).
The desktop's client. On a page where `globalThis.forgeWeb` exists and `set_canvas` named no canvas, `platform/web/desktop.rs` connects as the app's `app_id` and asks for the main surface in the role the app's hooks give, as a Wayland compositor reads them: `shell_mode`, and for a layer `layer_anchor`, `layer_size`, `exclusive_zone` and `keyboard_exclusive`, for a window `app_id` and `window_size_hint`, and for `SessionLock` a full overlay that holds the keyboard. The app draws into the canvas forge/web gives and listens on the input element above it, takes its size and scale from forge/web's `configure` rather than from the window's `resize`, sends its input region in logical pixels when it changes, and leaves the keyboard focus to the desktop, which honours the surfaces that hold it exclusively. A `close` of the main surface reaches `App::on_close_requested`; on exit the surface is destroyed and the connection closed. The new `RunError::Desktop` says forge/web refused the main surface. Fullscreen inside a desktop does nothing yet. A page without a desktop, or one that named a canvas, runs the app as before.
One WebGL context. A page holds few WebGL contexts, and the browser loses the oldest beyond them, while a shell has many surfaces. Every surface of an app now paints through one WebGL2 context on an `OffscreenCanvas`: the surface's FBO is blitted onto it, taken as an `ImageBitmap` and handed to the surface's own canvas, a `bitmaprenderer`, without a copy. Without WebGL2 each surface draws in software on a 2D context of its own. The context's loss is heard on the `OffscreenCanvas`, and now drops the GPU canvases of every surface, not only the main one's.
Overlays. `platform/web/overlays.rs` keeps the app's overlays in step with `App::overlays()`, as the Wayland backend's `overlays_reconcile.rs` does with layer-shell: a new one becomes a layer surface or, for `Layer::Window`, a window, with its size and exclusive zone in logical pixels; a changed one gets an `update_role`; one that went is destroyed; forge/web's `close` of one delivers its `on_dismiss`. Popups anchored to a widget and the tooltip wait for a popup role forge/web does not have, and without a desktop an app still shows no overlays. What every backend does as overlays come and go, resolving an `OverlaySpec::size` and moving a drag and the focuses off an overlay that goes, moved out of the Wayland backend into `event_loop/overlays.rs` (`AppData::overlay_size`, `AppData::remove_overlay`); Wayland's behaviour is unchanged.
Subsurfaces. `platform/web/subsurfaces.rs` makes each of `App::subsurfaces()` a surface of the desktop sized to its parent with an empty input region, painted on one shared GPU canvas only when it appears, its size changes or its `content_version` does, its frame then staying on its own canvas, and otherwise moved with an `update_role`, which the browser composes without painting. `App::on_subsurface_committed` follows each paint. A subsurface whose parent overlay was destroyed and made again is made again, since forge/web destroys a subsurface with its parent. `frame_presented` now knows whether a surface hosts subsurfaces, so while the app animates by subsurface motion only its frames keep coming without painting the parent, as Wayland keeps a parent's frame callbacks. Painting a view with no focus, hover or press on a canvas of its own, which both backends' subsurfaces do, moved out of the Wayland backend into `draw::paint_detached`.
Input per surface. `input::listen` takes the surface's `SurfaceFocus`, which pointer, touch and keyboard focus events carry; a surface's blur no longer takes the keyboard from the surface focused after it. Listeners remove themselves when dropped, and a surface's live in its `WebSurfaceState`, so they go with it. The frames owed at the next animation frame are a list of surfaces instead of one, which with several surfaces left the main one waiting on a frame that never came.
`examples/web_shell.rs` is a shell for the desktop: a background, a bar in the top layer with an exclusive zone, a panel that takes the keyboard, and a card on a subsurface that slides. `make web-example` builds it with web_hello (`WEB_EXAMPLES`); forge/web's `make hello` serves both at /shell.html. web-sys gains `OffscreenCanvas`, `ImageBitmap` and `ImageBitmapRenderingContext`. `CHANGELOG.md` describes the change.
Not done. Popups and the tooltip in a desktop, overlays and subsurfaces without one, fullscreen and window moves through forge/web, and the cursor's shape over an overlay, which is still set on the main surface's element. The offscreen canvas is resized for each surface it paints, which reallocates its drawing buffer when the surfaces differ in size. There are no tests for the browser backend.
This commit is contained in:
2026-10-19 22:42:12 +02:00
parent 95beeda66a
commit cd1a233b2e
20 changed files with 1086 additions and 266 deletions
+95 -44
View File
@@ -10,13 +10,15 @@ 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::{ CanvasRenderingContext2d, Document, Event, EventTarget, HtmlCanvasElement, WebGl2RenderingContext };
use web_sys::{ Document, Event, EventTarget, HtmlCanvasElement, HtmlElement, OffscreenCanvas, WebGl2RenderingContext };
use crate::app::App;
use crate::event_loop::{ AppData, RunError, Sources, SurfaceState };
use crate::event_loop::{ AppData, RunError, Sources, SurfaceFocus, SurfaceState };
use super::desktop::{ main_role, Connection, MAIN };
use super::input::WebInput;
use super::{ WebPlatform, WebSurfaceState };
@@ -27,9 +29,22 @@ pub( super ) enum PageEvent
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,
},
}
/// An event listener, registered while it lives in `WebPlatform::listeners`.
/// An event listener, registered for as long as it lives.
pub( crate ) struct Listener
{
target: EventTarget,
@@ -45,8 +60,11 @@ impl Listener
target.add_event_listener_with_callback( kind, closure.as_ref().unchecked_ref() ).map_err( |_| RunError::NoDocument )?;
Ok( Self { target: target.clone(), kind, closure } )
}
}
pub( super ) fn remove( &self )
impl Drop for Listener
{
fn drop( &mut self )
{
let _ = self.target.remove_event_listener_with_callback( self.kind, self.closure.as_ref().unchecked_ref() );
}
@@ -98,43 +116,60 @@ pub( crate ) fn try_run<A: App>( 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 ( canvas, own ) = match CANVAS.with( |c| c.borrow_mut().take() )
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 };
let ( canvas, target, own ) = match ( desktop.as_ref(), given )
{
Some( canvas ) => ( canvas, false ),
None => ( page_canvas( &document )?, true ),
( Some( desktop ), _ ) =>
{
let ( canvas, input ) = desktop.create_surface( MAIN, main_role( &app ) ).map_err( RunError::Desktop )?;
( 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 )
}
};
canvas.set_tab_index( 0 );
let style = canvas.style();
let _ = style.set_property( "touch-action", "none" );
let _ = style.set_property( "outline", "none" );
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 webgl = webgl2( &canvas );
let ctx2d = if webgl.is_none() { context_2d( &canvas ) } else { None };
if webgl.is_none() { web_sys::console::warn_1( &"[ltk] no WebGL2 context: drawing in software".into() ); }
crate::render::set_software_render( webgl.is_none() );
let mut listeners = super::input::listen( &canvas )?;
listeners.push( Listener::new( &window, "resize", |_| push( PageEvent::Resized ) )? );
if webgl.is_some()
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( &canvas, "webglcontextlost", |event|
listeners.push( Listener::new( offscreen, "webglcontextlost", |event|
{
event.prevent_default();
push( PageEvent::ContextLost );
} )? );
listeners.push( Listener::new( &canvas, "webglcontextrestored", |_| push( PageEvent::ContextRestored ) )? );
listeners.push( Listener::new( offscreen, "webglcontextrestored", |_| push( PageEvent::ContextRestored ) )? );
}
let inset_probe = if own { Some( inset_probe( &document )? ) } else { None };
if own { let _ = canvas.focus(); }
if own { let _ = target.focus(); }
let coarse = window.match_media( "(pointer: coarse)" ).ok().flatten().is_some_and( |m| m.matches() );
let platform = WebPlatform::new( canvas, webgl, ctx2d, listeners, inset_probe );
let main = SurfaceState::new( WebSurfaceState::default(), 0.0, String::new() );
let main = SurfaceState::new( WebSurfaceState::new( canvas, 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()?;
data.fit_canvas_element();
// 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<dyn FnMut( bool ) -> Next> = Box::new( move |in_frame|
@@ -247,16 +282,30 @@ impl<A: App> AppData<A>
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 } =>
{
if self.app.on_close_requested() { self.exit_requested = true; }
}
PageEvent::Close { surface } => self.dismiss_overlay( surface ),
}
}
self.dispatch_sources();
if in_frame
{
if let Some( focus ) = self.platform.frame_due.take() { self.frame_presented( focus, false ); }
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::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
@@ -264,8 +313,8 @@ impl<A: App> AppData<A>
self.leave_page();
return Next { frame: false, after: None, exit: true };
}
let main = &self.main;
let frame = ( main.configured && main.needs_redraw && !main.frame_pending ) || self.platform.frame_due.get().is_some();
let due = |ss: &SurfaceState<A::Message, WebSurfaceState>| 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()
@@ -289,17 +338,22 @@ impl<A: App> AppData<A>
self.sources.restore_channels( drains );
}
/// Stop listening to the page and tell it, with an `ltk-exit` event on the canvas, that the app has ended.
/// 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 )
{
for listener in &self.platform.listeners
{
listener.remove();
}
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.platform.canvas.dispatch_event( &event );
let _ = self.main.platform.canvas.dispatch_event( &event );
}
}
}
@@ -325,18 +379,15 @@ fn inset_probe( document: &Document ) -> Result<web_sys::Element, RunError>
Ok( probe )
}
/// The WebGL2 context ltk's GPU canvas draws through: premultiplied alpha as it writes it, and no depth, stencil or multisampling, which it does not use.
fn webgl2( canvas: &HtmlCanvasElement ) -> Option<WebGl2RenderingContext>
/// 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 options = js_sys::Object::new();
for ( key, value ) in [ ( "alpha", true ), ( "premultipliedAlpha", true ), ( "antialias", false ), ( "depth", false ), ( "stencil", false ), ( "preserveDrawingBuffer", false ) ]
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()?;
}
canvas.get_context_with_context_options( "webgl2", &options ).ok()??.dyn_into().ok()
}
fn context_2d( canvas: &HtmlCanvasElement ) -> Option<CanvasRenderingContext2d>
{
canvas.get_context( "2d" ).ok()??.dyn_into().ok()
let context = offscreen.get_context_with_context_options( "webgl2", &options ).ok()??.dyn_into().ok()?;
Some( ( offscreen, context ) )
}