A browser app takes part in the desktop as a Wayland client does: fullscreen, the desktop's windows, activation and the shell protocol through forge/web, its own files through ltk::vfs, and images its caller names
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

On a page with forge/web an ltk app had a main surface, overlays and subsurfaces, but none of what a shell such as crustace needs from the compositor: fullscreen did nothing, the app heard nothing of the other windows, an activation token it asked for never came, forge/web was never told when a launched window had shown, the shell could not speak its protocol, and the app had no way to read its own files in a browser. Each now goes through the app's connection to forge/web, in the shape of the Wayland protocol it stands for.
Fullscreen. On a page with forge/web, `App::fullscreen` and `App::start_fullscreen` send a `set_fullscreen` request for the main window, as `xdg_toplevel.set_fullscreen` does, instead of doing nothing; alone on a page the canvas still takes the browser's fullscreen. `examples/web_hello.rs` gains a button that toggles it.
The desktop's windows. On connecting, the backend asks for the list of toplevels, and forge/web's `toplevel_opened` and `toplevel_closed` events become `ToplevelEvent`s for `App::on_toplevel_event`, as `ext-foreign-toplevel-list-v1` does on Wayland, so a shell's dock knows which applications have windows open.
Activation. The tokens an app asks for with `App::take_activation_requests` are issued before it draws: forge/web's, through its `activation_token` request, or empty without a desktop, as on Wayland without xdg-activation, and handed to `App::on_activation_token` with their messages processed. A browser app that launched others through tokens waited for one that never came. Each surface's first presented frame is reported with a `presented` request (`WebSurfaceState::presented`), so forge/web knows when a window it launched has shown and can report it activated, which dismisses crustace's splash.
The shell protocol. `platform/web/shell.rs` carries the JSON a shell speaks to forge over its socket over the app's connection to forge/web: `ltk::web::shell_send( json )` sends it as a `shell` request, and each `shell` event's message reaches the listener `ltk::web::set_shell_listener` gives, as JSON. What is sent before the app has connected is kept and sent once it has, so a shell can tell forge/web its theme while it starts. An overlay's role now carries its `App::overlay_namespace`, as its layer-shell namespace does on Wayland, so forge/web can tell a shell's overlays apart.
The cursor over every surface. The CSS `cursor` was set on the main surface's element only, so over an overlay the pointer kept the page's arrow. `WebSurfaceState` keeps the element that takes its input, the shape moves to that of the surface the pointer enters, and `WebPlatform::cursor` keeps the last one asked for.
An app's own files. `ltk::vfs`, through which ltk reads its themes, fonts and keyboard layouts, is public: `read`, `read_to_string`, `read_dir`, `is_file` and `is_dir` read the file system natively and, in a browser, the files the page loaded into memory. `ltk::web::load_tree( base, root ).await` loads any tree served with a `MANIFEST` under `root`, and `load_resources` is that into `/ltk` with the directory made the resource directory. `ltk::time::Instant` is public too, `std`'s natively and `web_time`'s on wasm32, where `std::time::Instant::now` panics, so an app that builds for both measures time with ltk's clock.
wasm-bindgen-cli. `scripts/wasm-bindgen-cli.sh TOOLS_DIR` builds wasm-bindgen-cli into `TOOLS_DIR/bin` at the version of the `wasm-bindgen` crate ltk pins in its `Cargo.toml`, from its sources with a lock resolved for Debian's rustc, which is what `make web-deps` did inline and now runs. Since `make install` copies `scripts/` into the registry, a project that builds for the browser against the installed ltk, such as forge/web or crustace, gets the same CLI without a checkout of ltk. The README and docs/architecture.md say so.
Images named by their caller. Every draw through `draw_image_data` fingerprints its pixels to find them in the image caches, which for a terminal means hashing every glyph of every frame. `Canvas::draw_image_keyed( key, rgba, w, h, dest, opacity )` is `draw_image_data` with `key` naming the pixels in place of their fingerprint: every draw with the same key must pass the same pixels, which are read only when the image is not cached. Both backends' caches are keyed by `ImageId`, `Content` for a fingerprint and `Keyed` for a caller's key, so a key never names an image drawn by content even when it equals its fingerprint. `GlesCanvas` and `SoftwareCanvas` gain `draw_image_keyed`, and their `draw_image_data` and it share one private `draw_image`. Tests check that a keyed image draws as its pixels do and that a key equal to a fingerprint is another image.
A held button is not also tapped. A button with `on_long_press` and no `on_drag_start` stayed a tap after its hold fired, so releasing it sent its `on_press` as well. `GestureState::long_press_msg_fired` is set when the hold fires `long_press_msg` and suppresses the press message on release; a test covers it.
`CHANGELOG.md` describes these changes.
This commit is contained in:
2026-10-21 10:21:11 +02:00
parent cd1a233b2e
commit af4009374d
27 changed files with 369 additions and 69 deletions
+9 -2
View File
@@ -18,6 +18,14 @@ const ROOT: &str = "/ltk";
///
/// The files are fetched at once, each with the manifest's hash in its URL, so a changed tree is never served from the browser's cache; the manifest itself always comes from the server.
pub async fn load_resources( base: &str ) -> Result<(), String>
{
load_tree( base, ROOT ).await?;
crate::resources::set_resource_dir( ROOT );
Ok( () )
}
/// Fetch the tree served at `base`, listed by its `MANIFEST` as ltk's resources are, into memory under `root`, an absolute path such as `"/apps"`, where [`crate::vfs`] reads it. An app loads its own files this way, as crustace does its catalog of applications.
pub async fn load_tree( base: &str, root: &str ) -> Result<(), String>
{
let window = web_sys::window().ok_or( "no window" )?;
let base = base.trim_end_matches( '/' );
@@ -38,9 +46,8 @@ pub async fn load_resources( base: &str ) -> Result<(), String>
for ( path, request ) in pending
{
let bytes = body( request ).await.map_err( |e| format!( "{path}: {e}" ) )?;
crate::vfs::insert( Path::new( ROOT ).join( path ), bytes );
crate::vfs::insert( Path::new( root ).join( path ), bytes );
}
crate::resources::set_resource_dir( ROOT );
Ok( () )
}
+45 -2
View File
@@ -8,9 +8,11 @@ use wasm_bindgen::closure::Closure;
use wasm_bindgen::{ JsCast, JsValue };
use web_sys::{ HtmlCanvasElement, HtmlElement };
use crate::app::{ App, Layer, ShellMode };
use crate::app::{ App, Layer, ShellMode, ToplevelEvent };
use crate::event_loop::AppData;
use super::run::{ push, PageEvent };
use super::shell;
/// The id the main surface goes by.
pub( super ) const MAIN: u32 = 0;
@@ -32,7 +34,13 @@ impl Connection
let on_event = Closure::<dyn FnMut( String )>::new( on_event );
let connection = connect.call2( &api, &JsValue::from_str( app_id ), on_event.as_ref() ).ok()?;
let method = |name: &str| js_sys::Reflect::get( &connection, &name.into() ).ok().and_then( |f| f.dyn_into::<js_sys::Function>().ok() );
Some( Self { request: method( "request" )?, disconnect: method( "disconnect" )?, _on_event: on_event } )
let this = Self { request: method( "request" )?, disconnect: method( "disconnect" )?, _on_event: on_event };
shell::connected( Some( this.request.clone() ) );
if let Err( e ) = this.request( &json!( { "type": "list_toplevels" } ) )
{
web_sys::console::warn_1( &format!( "[ltk] list_toplevels: {e}" ).into() );
}
Some( this )
}
pub( super ) fn request( &self, message: &Value ) -> Result<JsValue, String>
@@ -51,8 +59,18 @@ impl Connection
Ok( ( canvas, input ) )
}
/// Ask for `surface`, a window, to fill the desktop or to go back.
pub( super ) fn set_fullscreen( &self, surface: u32, on: bool )
{
if let Err( e ) = self.request( &json!( { "type": "set_fullscreen", "surface": surface, "on": on } ) )
{
web_sys::console::warn_1( &format!( "[ltk] set_fullscreen: {e}" ).into() );
}
}
pub( super ) fn close( &self )
{
shell::connected( None );
let _ = self.disconnect.call0( &JsValue::NULL );
}
}
@@ -71,10 +89,35 @@ fn on_event( json: String )
scale: event[ "scale" ].as_f64().unwrap_or( 1.0 ),
} ),
Some( "close" ) => push( PageEvent::Close { surface } ),
Some( "shell" ) => shell::deliver( &event[ "message" ] ),
Some( "toplevel_opened" ) => push( PageEvent::Toplevel( ToplevelEvent::Opened
{
id: event[ "id" ].as_u64().unwrap_or( 0 ) as u32,
app_id: event[ "app_id" ].as_str().unwrap_or_default().to_string(),
identifier: event[ "identifier" ].as_str().unwrap_or_default().to_string(),
title: event[ "title" ].as_str().unwrap_or_default().to_string(),
} ) ),
Some( "toplevel_closed" ) => push( PageEvent::Toplevel( ToplevelEvent::Closed { id: event[ "id" ].as_u64().unwrap_or( 0 ) as u32 } ) ),
_ => web_sys::console::warn_1( &format!( "[ltk] forge/web: {json}" ).into() ),
}
}
/// Issue the activation tokens the app asked for and hand them to it before it draws, as xdg-activation does on Wayland: forge/web's, or empty without a desktop, so the app still goes ahead.
pub( super ) fn issue_activation_tokens<A: App>( data: &mut AppData<A> )
{
let tags = data.app.take_activation_requests();
if tags.is_empty() { return; }
for tag in tags
{
let token = data.platform.desktop.as_ref()
.and_then( |d| d.request( &json!( { "type": "activation_token" } ) ).ok() )
.and_then( |token| token.as_string() )
.unwrap_or_default();
if let Some( msg ) = data.app.on_activation_token( tag, token ) { data.pending_msgs.push( msg ); }
}
data.process_messages();
}
pub( super ) fn layer_name( layer: Layer ) -> &'static str
{
match layer
+18 -1
View File
@@ -73,7 +73,11 @@ impl<A: App> AppData<A>
{
match input
{
WebInput::Pointer( pointer ) => self.pointer_input( &pointer ),
WebInput::Pointer( pointer ) =>
{
if pointer.kind == PointerKind::Enter { self.point_cursor_at( pointer.focus ); }
self.pointer_input( &pointer );
}
WebInput::Touch( touch ) => self.touch_input( &touch ),
WebInput::TouchCancel => { self.reset_touch_state(); }
WebInput::Key { key, action, ctrl, shift, alt } =>
@@ -101,6 +105,19 @@ impl<A: App> AppData<A>
WebInput::Clipboard( text ) => self.clipboard = text,
}
}
/// Show the cursor's shape on the surface `focus` names, which the pointer entered, as the cursor follows the pointer on Wayland.
fn point_cursor_at( &mut self, focus: SurfaceFocus )
{
let input = match focus
{
SurfaceFocus::Main => Some( &self.main.platform.input ),
SurfaceFocus::Overlay( id ) => self.overlays.get( &id ).map( |ss| &ss.platform.input ),
};
let Some( input ) = input.cloned() else { return };
let _ = input.style().set_property( "cursor", self.platform.cursor );
self.platform.cursor_target = input;
}
}
/// Listen on `target`, which takes the input of the surface `focus` names, for every input the runtime takes. A press focuses `target` when `take_focus`; in a desktop the desktop gives the keyboard.
+12 -4
View File
@@ -7,6 +7,7 @@
//! | --- | --- |
//! | `assets.rs` | The themes, fonts and keyboard layouts a page serves, fetched into memory as the app's resource directory |
//! | `desktop.rs` | The connection to forge/web and the main surface's role |
//! | `shell.rs` | The shell protocol over that connection, for a shell such as crustace: [`shell_send`] and [`set_shell_listener`] |
//! | `overlays.rs` | The app's overlays as surfaces of the desktop, kept in step with `App::overlays()` |
//! | `subsurfaces.rs` | The app's subsurfaces as surfaces of the desktop, painted when their content changes and otherwise only moved |
//! | `log.rs` | Panics into the browser's console |
@@ -30,12 +31,14 @@ mod log;
mod overlays;
mod present;
pub( crate ) mod run;
mod shell;
mod state;
mod subsurfaces;
mod surface;
pub use assets::load_resources;
pub use assets::{ load_resources, load_tree };
pub use run::set_canvas;
pub use shell::{ set_shell_listener, shell_send };
pub( crate ) use state::WebPlatform;
pub( crate ) use surface::WebSurfaceState;
@@ -69,10 +72,14 @@ impl<A: App> Platform for WebPlatform<A>
fn minimize_window( &self, _main: &WebSurfaceState ) {}
/// In a desktop a window's fullscreen is forge/web's to give, which it does not yet.
/// In a desktop a window's fullscreen is forge/web's to give; alone, the page's canvas takes the browser's.
fn set_fullscreen( &self, main: &WebSurfaceState, fullscreen: bool ) -> bool
{
if self.desktop.is_some() { return true; }
if let Some( desktop ) = self.desktop.as_ref()
{
desktop.set_fullscreen( main.id, fullscreen );
return true;
}
lock_keyboard( fullscreen );
if fullscreen
{
@@ -113,7 +120,8 @@ impl<A: App> Platform for WebPlatform<A>
fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool
{
self.cursor_target.style().set_property( "cursor", cursor::css( shape ) ).is_ok()
self.cursor = cursor::css( shape );
self.cursor_target.style().set_property( "cursor", self.cursor ).is_ok()
}
fn text_input_active( &self ) -> bool
+2 -1
View File
@@ -32,6 +32,7 @@ fn overlay_role<A: App>( app: &A, spec: &OverlaySpec<A::Message>, ( width, heigh
"height": logical( height ),
"exclusive_zone": zone,
"keyboard": if spec.keyboard_exclusive { "exclusive" } else { "on_demand" },
"namespace": app.overlay_namespace( spec.id ),
} )
}
@@ -86,7 +87,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
Vec::new()
}
};
let mut platform = WebSurfaceState::new( canvas, id, gpu, listeners );
let mut platform = WebSurfaceState::new( canvas, input, id, gpu, listeners );
platform.role = Some( role );
let mut ss = SurfaceState::new( platform, 0.0, String::new() );
// The main surface's scale until forge/web configures this one, so the first frame lays out at the right size.
+10 -1
View File
@@ -72,7 +72,16 @@ pub( super ) fn present<A: App>
( _, _, Some( _ ) ) => present_software( surface, canvas ),
_ => false,
};
if let Some( desktop ) = platform.desktop.as_ref() { sync_input_region( desktop, surface, frame ); }
if let Some( desktop ) = platform.desktop.as_ref()
{
sync_input_region( desktop, surface, frame );
if shown && !surface.presented
{
surface.presented = true;
let request = json!( { "type": "presented", "surface": surface.id } );
if let Err( e ) = desktop.request( &request ) { web_sys::console::warn_1( &format!( "[ltk] presented: {e}" ).into() ); }
}
}
if let ( true, Some( focus ) ) = ( shown, frame.frame ) { platform.owe_frame( focus ); }
shown
}
+10 -2
View File
@@ -15,7 +15,7 @@ use wasm_bindgen::closure::Closure;
use wasm_bindgen::{ JsCast, JsValue };
use web_sys::{ Document, Event, EventTarget, HtmlCanvasElement, HtmlElement, OffscreenCanvas, WebGl2RenderingContext };
use crate::app::App;
use crate::app::{ App, ToplevelEvent };
use crate::event_loop::{ AppData, RunError, Sources, SurfaceFocus, SurfaceState };
use super::desktop::{ main_role, Connection, MAIN };
@@ -42,6 +42,8 @@ pub( super ) enum PageEvent
{
surface: u32,
},
/// A window of the desktop's, as `ext-foreign-toplevel-list` reports it.
Toplevel( ToplevelEvent ),
}
/// An event listener, registered for as long as it lives.
@@ -129,6 +131,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
( 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 ),
@@ -163,7 +166,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
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, MAIN, offscreen.is_some(), input ), 0.0, String::new() );
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 );
@@ -289,6 +292,10 @@ impl<A: App> AppData<A>
if self.app.on_close_requested() { self.exit_requested = true; }
}
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 ); }
}
}
}
self.dispatch_sources();
@@ -302,6 +309,7 @@ impl<A: App> AppData<A>
}
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 );
+73
View File
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The shell protocol over the app's connection to forge/web (forge/web/DESIGN.md, section 7): the JSON a shell speaks to forge over its socket, sent as a `shell` request and received as a `shell` event. What is sent before the app has connected waits for the connection.
use std::cell::RefCell;
use serde_json::{ json, Value };
use wasm_bindgen::JsValue;
thread_local!
{
static REQUEST: RefCell<Option<js_sys::Function>> = const { RefCell::new( None ) };
static PENDING: RefCell<Vec<Value>> = const { RefCell::new( Vec::new() ) };
static LISTENER: RefCell<Option<Box<dyn Fn( &str )>>> = const { RefCell::new( None ) };
}
/// Send `message`, one of the shell protocol's JSON objects, to forge/web.
pub fn shell_send( message: &str )
{
let message: Value = match serde_json::from_str( message )
{
Ok( m ) => m,
Err( e ) =>
{
web_sys::console::warn_1( &format!( "[ltk] shell message is not JSON ({e}): {message}" ).into() );
return;
}
};
let request = REQUEST.with( |r| r.borrow().clone() );
match request
{
Some( request ) => send( &request, message ),
None => PENDING.with( |p| p.borrow_mut().push( message ) ),
}
}
/// Have `listener` receive each of forge/web's shell messages as JSON.
pub fn set_shell_listener( listener: impl Fn( &str ) + 'static )
{
LISTENER.with( |l| *l.borrow_mut() = Some( Box::new( listener ) ) );
}
fn send( request: &js_sys::Function, message: Value )
{
let json = json!( { "type": "shell", "message": message } ).to_string();
if let Err( e ) = request.call1( &JsValue::NULL, &JsValue::from_str( &json ) )
{
web_sys::console::warn_2( &"[ltk] forge/web refused a shell message:".into(), &e );
}
}
/// The connection's `request`, once connected, and `None` once it has closed.
pub( super ) fn connected( request: Option<js_sys::Function> )
{
if let Some( request ) = &request
{
for message in PENDING.with( |p| std::mem::take( &mut *p.borrow_mut() ) )
{
send( request, message );
}
}
REQUEST.with( |r| *r.borrow_mut() = request );
}
pub( super ) fn deliver( message: &Value )
{
let json = message.to_string();
LISTENER.with( |l|
{
if let Some( listener ) = l.borrow().as_ref() { listener( &json ); }
} );
}
+4 -1
View File
@@ -28,8 +28,10 @@ pub( crate ) struct WebPlatform<A: App>
pub gl: Option<Arc<glow::Context>>,
/// The browser took the WebGL context away; nothing is drawn until it gives it back.
pub context_lost: bool,
/// Where the cursor's shape is set: the element under the pointer that takes the main surface's input.
/// Where the cursor's shape is set: the element taking the input of the surface the pointer last entered, since CSS gives each element its own.
pub cursor_target: HtmlElement,
/// The cursor's shape as CSS names it, for the next surface the pointer enters.
pub cursor: &'static str,
/// A hidden element padded by `env( safe-area-inset-* )`, which tells what a phone's notch and bars cover; only on ltk's own canvas over the page.
pub inset_probe: Option<Element>,
/// The surfaces a frame was presented or asked for; the loop reports them at the next animation frame.
@@ -61,6 +63,7 @@ impl<A: App> WebPlatform<A>
gl,
context_lost: false,
cursor_target,
cursor: "default",
inset_probe,
frame_due: RefCell::new( Vec::new() ),
next_surface: Cell::new( super::desktop::MAIN + 1 ),
+2 -2
View File
@@ -151,12 +151,12 @@ pub( super ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
fn create<Msg: Clone>( desktop: &Connection, id: u32, spec: &SubsurfaceSpec<Msg>, parent: &Parent, gpu: bool ) -> Option<WebSubsurface>
{
let ( canvas, _input ) = desktop.create_surface( id, role( spec.x, spec.y, parent ) ).map_err( |e| warn( spec.id, &e ) ).ok()?;
let ( canvas, input ) = desktop.create_surface( id, role( spec.x, spec.y, parent ) ).map_err( |e| warn( spec.id, &e ) ).ok()?;
// Input falls through to the parent, which keeps one gesture model for both.
if let Err( e ) = desktop.request( &json!( { "type": "set_input_region", "surface": id, "rects": [] } ) ) { warn( spec.id, &e ); }
Some( WebSubsurface
{
surface: WebSurfaceState::new( canvas, id, gpu, Vec::new() ),
surface: WebSurfaceState::new( canvas, input, id, gpu, Vec::new() ),
canvas: None,
parent: spec.parent,
parent_id: parent.id,
+7 -3
View File
@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use serde_json::Value;
use web_sys::{ CanvasRenderingContext2d, Element, HtmlCanvasElement, ImageBitmapRenderingContext };
use web_sys::{ CanvasRenderingContext2d, Element, HtmlCanvasElement, HtmlElement, ImageBitmapRenderingContext };
use crate::app::App;
use crate::event_loop::AppData;
@@ -14,6 +14,8 @@ use super::run::Listener;
pub( crate ) struct WebSurfaceState
{
pub canvas: HtmlCanvasElement,
/// Takes the surface's input: the element forge/web gives above the canvas, or the canvas itself without a desktop.
pub input: HtmlElement,
/// Takes the frames painted on the shared WebGL context.
pub bitmap: Option<ImageBitmapRenderingContext>,
/// Takes the software renderer's frames, without WebGL2.
@@ -28,17 +30,19 @@ pub( crate ) struct WebSurfaceState
pub role: Option<Value>,
/// The surface's input, removed with it.
pub listeners: Vec<Listener>,
/// A frame has been shown, which forge/web was told of: its first, after which a launched window counts as activated.
pub presented: bool,
}
impl WebSurfaceState
{
/// The surface shown on `canvas`, through the shared WebGL context when there is one, else in software.
pub( super ) fn new( canvas: HtmlCanvasElement, id: u32, gpu: bool, listeners: Vec<Listener> ) -> Self
pub( super ) fn new( canvas: HtmlCanvasElement, input: HtmlElement, id: u32, gpu: bool, listeners: Vec<Listener> ) -> Self
{
let context = |kind: &str| canvas.get_context( kind ).ok().flatten();
let bitmap = if gpu { context( "bitmaprenderer" ).and_then( |c| wasm_bindgen::JsCast::dyn_into( c ).ok() ) } else { None };
let ctx2d = if gpu { None } else { context( "2d" ).and_then( |c| wasm_bindgen::JsCast::dyn_into( c ).ok() ) };
Self { canvas, bitmap, ctx2d, pixels: Vec::new(), id, input_region: None, role: None, listeners }
Self { canvas, input, bitmap, ctx2d, pixels: Vec::new(), id, input_region: None, role: None, listeners, presented: false }
}
}