A multiline text field was a box for a few lines: it wrapped its whole value by shaping each character alone on every frame, every click and every caret motion, so a file of some size made it crawl; it drew a tab as the font's glyph for it, had no scroll bar, no page keys and no way to number its lines, kept no record of the view it showed, so once the cursor had left the first screen it stayed on the last row and the arrows moved the text, and an app could neither colour its text, type a tab with Tab, give it a face of its own, ask it to copy, cut, paste or select, nor hear where its cursor was. Notes, the Eydos text editor, needs all of it, and every app that opens or saves the user's files needed a dialog of its own.
Speed. `Canvas::measure_char` measures a character's advance once per font and size and keeps it (`text_shaping::char_advance_cached`, emptied with the shaped lines), and `compute_visual_lines` keeps the last four layouts by the value's hash and length, the row width, the size and the face, so a value is wrapped again only when one of them changes, and drawing, hit testing and caret motion over the same value wrap it once.
The view. `TextEdit::draw` returns the view it showed, which the frame keeps in `text_scroll`, so the view moves only when the cursor leaves it, and by the least; a `TextCommand::Select` no longer resets it. `Window::shown` gives the lines as the last frame showed them, wherever the cursor went since, and a press, the wheel, the scroll bar and the page keys act on them: a press that gave a field the focus, which puts its cursor at the end, landed where the view would have gone to show that cursor, near the end of the text, instead of on the character pressed. The cursor a scroll keeps is taken back to the value's length as the view's is, so a scroll made with the cursor at its end sentinel holds instead of going back to the cursor at once.
Tabs. In a multiline field a tab reaches the next stop, every four digits' widths, as four monospace columns are (`theme::TAB_COLUMNS`, `text_edit/tabs.rs`), when it is drawn, selected, clicked and crossed with the arrows. `TextEdit::tab_text( text )` has Tab type `text` while the field has the focus instead of moving the focus on; Shift+Tab still moves it back and Ctrl+Tab still reaches the app.
The scroll bar. A field whose lines do not all fit shows a scroll bar along its right edge (`Window::scrollbar`): its thumb is held and dragged, or a press on its track brings the thumb there, by pointer or touch, without moving the cursor (`AppData::text_scrollbar_grab`, `handle_text_scrollbar_press`, `handle_text_scrollbar_drag`); the pointer over it is the arrow rather than the text cursor, and a long press that holds it no longer opens the Copy / Cut / Paste menu.
Page Up and Page Down move the cursor of a multiline field by the lines on screen but one, at the same height on screen, and the view with it, to the start or the end from the first or the last page (`cursor_visual_page`, `handle_cursor_page`); Shift extends the selection, and with Ctrl, or in any other widget, the keys still reach the app. A key without Ctrl that types no text, such as F3, was dropped while a text field had the focus; it now goes to `App::on_key_with_modifiers`, as it does with no field focused.
The text as an editor's. `TextEdit::highlights( Arc<Vec<TextHighlight>> )` draws runs of a multiline value, byte ranges in order, in colours of their own, what no run covers in the field's colour and a run off a character boundary left out. `TextEdit::font( face )` lays a multiline field's text out in a face of its own at the field's size: wrapping, drawing, the selection, the cursor, clicks, tab stops and the arrows all measure through one `TextFont`, the size and the face, which `text_input_geometry` now gives the runtime's paths where it gave them the size; `Canvas::measure_char_with_font` is `measure_char` in another face. `ltk::monospace_font()` finds a monospace face, DejaVu Sans Mono from the app's resources, which a page and an APK carry, else the first of the system's installed, loaded once. `TextEdit::line_numbers( true )` numbers the lines in a column at the field's left, in the monospace face when there is one, as wide as the number of the last line needs, a wrapped line numbered on its first row and the cursor's in the text colour while the field has the focus; the text starts right of the column alike when drawn and in every runtime path, since they all lay it out in the rect `gutter::text_rect` gives. `borderless()` now also drops a multiline field's background and border.
Talking to the field. `App::take_text_command` hands a field, by its `WidgetId`, a `TextCommand` after the next frame: `Copy`, `Cut`, `Paste` and `SelectAll` through the paths the keys take, and `Select { start, end }`, which focuses the field, selects the range with its ends taken back to the value and to its characters' starts, and scrolls to it. `Reveal { start, end }` does the same without moving the focus, as a search does while its own field keeps the keys, and a multiline field without the focus now shows its selection dimmed (`theme::selection_inactive`). `TextEdit::on_cursor( |at| … )` sends the cursor's byte offset once it is placed and whenever it moves or the value changes length (`report_text_cursors`, after every frame), as an editor's status bar showing the line needs.
A file chooser. `ltk::FileChooser` (`src/file_chooser/`) picks a file to open or a folder and a name to save one as, over `ltk::files`, alike natively and in a browser. The app holds it, puts its `view()` in its own and hands its `FileChooserMsg`s to `update()`, which returns `Chosen::Path` or `Chosen::Cancelled` once the user chose; `wrap` makes the app's message of the chooser's, so its listings reach it through the app's `ChannelSender`. It offers the home folder, the xdg-user-dirs folders that exist, the root and the folders mounted in a browser, with a press there to mount another; it lists folders first, the hidden entries left out, each with its type's icon, a file with its size and the date of its last access or change, sorted as Crate sorts, by name, last access or size, each key in a direction of its own (`Sort`, `SortKey`, `Directions`). `dir` starts elsewhere, `filter( &[ "text/*" ] )` shows only some types of files, `compact( true )` puts the places behind a button for a phone, `sort` starts in an order kept from `current_sort`, and `take_focus_request` gives the field to focus. Saving asks before replacing a file and can create a folder; its errors are in the user's words, and its strings in ltk's seven locales. `files::mounts()` gives the folders mounted in a browser as the desktop last said, which the web backend now keeps. `docs/widgets.md` describes it and `examples/file_chooser.rs` shows it, in `make examples`.
ltk::files::callback. The operations of `ltk::files` with a function in place of a message (`list`, `stat`, `read`, `read_range`, `write`, `create_dir`, `rename`, `remove`, `remove_all`, `copy`, `copy_all`, `mount` and `unmount`), for code with no run loop's `ChannelSender` to take one, such as Prompt's session daemon, which in a browser runs in the page beside the window. `done` is called on ltk's worker thread natively and on the page's thread in a browser.
Closing. An app that keeps its window open in `on_close_requested`, to ask whether to save, was not drawn again, so its question appeared only at the next event, such as a press. Every close request, from the compositor, the title bar's close button, back and the browser, now goes through `AppData::request_close`, which draws the view again when the app refused it.
`App::window_title`. The main window's title, read when the window is made, which the title bar, the task switcher and the dock show; by default the `app_id`. A `ShellMode::Window` app was titled "ltk" on Wayland, whatever the app, with no way to name it but the deprecated `window_config`; in a browser its title was its `app_id`, and is now this.
`Text::fit` and `Text::valign`. `fit()` draws a text at the largest size up to its own at which it fits the rect the layout gives it, on one line within its width and height or, with `wrap`, all its lines within its height, so a text whose length varies, such as a calculator's display, neither overflows a box the window sizes nor needs its size worked out from the window and its content; its preferred size is still the one at its own size. `valign( VAlign )` places the lines at the top, the centre or the bottom of the rect.
A stack keeps its children inside it. A child that `Stack` aligned to an edge or the centre took its preferred size even when that was larger than the stack, so one aligned to the bottom started above the stack's top and one aligned to the right left of its left edge, drawn over whatever was there. Each child's preferred size is now capped by the stack's, less its margin, before it is aligned; children placed with `push_placed` and the offsets of `push_translated` are as they were.
Tests cover Tab with and without `tab_text`, the text commands, `Reveal` leaving the focus alone, a highlight into a character, `on_cursor` reporting once, a refused close drawing again, a key that types nothing reaching the app from a field, tab stops, a click on a tab, the kept layouts, the scroll bar, a view kept while the cursor moves inside it and after a scroll with the cursor at its sentinel, a click landing on the view shown, the page keys, the width of the line numbers, the file chooser's listing, filters, names, user folders, sizes, sorting and its open, save and replace flows, `Text::fit` and a stack's aligned children. `CHANGELOG.md` and `docs/widgets.md` describe the changes.
429 lines
15 KiB
Rust
429 lines
15 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
//! `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<crate::files::Mount> ),
|
|
/// The files the app was launched to open.
|
|
OpenFiles( Vec<std::path::PathBuf> ),
|
|
/// 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<dyn FnMut( Event )>,
|
|
}
|
|
|
|
impl Listener
|
|
{
|
|
pub( super ) fn new( target: &EventTarget, kind: &'static str, handle: impl FnMut( Event ) + 'static ) -> Result<Self, RunError>
|
|
{
|
|
let closure = Closure::<dyn FnMut( Event )>::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<Duration>,
|
|
exit: bool,
|
|
}
|
|
|
|
struct Loop
|
|
{
|
|
iterate: RefCell<Box<dyn FnMut( bool ) -> Next>>,
|
|
/// The pending timeout and when it fires, in milliseconds since the epoch.
|
|
timeout: Cell<Option<( i32, f64 )>>,
|
|
frame: Cell<Option<i32>>,
|
|
ended: Cell<bool>,
|
|
on_timeout: Closure<dyn FnMut()>,
|
|
on_frame: Closure<dyn FnMut( f64 )>,
|
|
}
|
|
|
|
thread_local!
|
|
{
|
|
static LOOP: RefCell<Option<Rc<Loop>>> = const { RefCell::new( None ) };
|
|
static EVENTS: RefCell<VecDeque<PageEvent>> = const { RefCell::new( VecDeque::new() ) };
|
|
static CANVAS: RefCell<Option<HtmlCanvasElement>> = 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<A: App>( 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<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 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<dyn FnMut( bool ) -> 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<A: App> AppData<A>
|
|
{
|
|
/// 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<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()
|
|
.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<HtmlCanvasElement, RunError>
|
|
{
|
|
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<web_sys::Element, RunError>
|
|
{
|
|
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 ) )
|
|
}
|