Initial implementation of a Slint event loop sitting on top of Node.js

At the moment this is implemented using polling.

cc #2477
This commit is contained in:
Simon Hausmann 2023-08-09 11:34:20 +02:00 committed by Simon Hausmann
parent ee9f1a52a8
commit 7b61e455eb
17 changed files with 361 additions and 66 deletions

View file

@ -24,10 +24,12 @@ napi = { version = "2.12.0", default-features = false, features = ["napi8"] }
napi-derive = "2.12.2"
i-slint-compiler = { workspace = true, features = ["default"] }
i-slint-core = { workspace = true, features = ["default"] }
i-slint-backend-selector = { workspace = true }
slint-interpreter = { workspace = true, features = ["default", "display-diagnostics", "internal"] }
spin_on = "0.1"
css-color-parser2 = { workspace = true }
itertools = { workspace = true }
send_wrapper = { workspace = true }
[build-dependencies]
napi-build = "2.0.1"

View file

@ -0,0 +1,80 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-1.1 OR LicenseRef-Slint-commercial
// Test that the Slint event loop processes libuv's events.
import test from 'ava'
import * as http from 'http';
import fetch from "node-fetch";
import { run_event_loop, quit_event_loop, private_api } from '../index'
test.serial('merged event loops with timer', async (t) => {
let invoked = false;
await run_event_loop(() => {
setTimeout(() => {
invoked = true;
quit_event_loop();
}, 2);
});
t.true(invoked)
})
test.serial('merged event loops with networking', async (t) => {
const listener = (request, result) => {
result.writeHead(200);
result.end("Hello World");
};
let received_response = "";
await run_event_loop(() => {
const server = http.createServer(listener);
server.listen(async () => {
let host = "localhost";
let port = (server.address() as any).port;
console.log(`server ready at ${host}:${port}`);
fetch(`http://${host}:${port}/`).then(async (response) => {
return response.text();
}).then((text) => {
received_response = text;
//console.log("received ", text);
quit_event_loop();
server.close();
});
});
})
t.is(received_response, "Hello World");
})
test.serial('quit event loop on last window closed', async (t) => {
let compiler = new private_api.ComponentCompiler;
let definition = compiler.buildFromSource(`
export component App inherits Window {
width: 300px;
height: 300px;
}`, "");
t.not(definition, null);
let instance = definition!.create() as any;
t.not(instance, null);
instance.window().show();
await run_event_loop(() => {
setTimeout(() => {
instance.window().hide();
}, 2);
});
})

View file

@ -3,7 +3,7 @@
import test from 'ava';
import { SlintBrush, SlintRgbaColor, Brush, ArrayModel, Timer } from '../index'
import { SlintBrush, SlintRgbaColor, Brush, ArrayModel } from '../index'
test('SlintColor from fromRgb', (t) => {
let color = SlintRgbaColor.fromRgb(100, 110, 120);
@ -56,7 +56,7 @@ test('SlintBrush from RgbaColor', (t) => {
})
test('SlintBrush from Brush', (t) => {
let brush = SlintBrush.fromBrush({ color: { red: 100, green: 110, blue: 120, alpha: 255 }});
let brush = SlintBrush.fromBrush({ color: { red: 100, green: 110, blue: 120, alpha: 255 } });
t.deepEqual(brush.color.red, 100);
t.deepEqual(brush.color.green, 110);
@ -105,15 +105,3 @@ test('ArrayModel remove', (t) => {
t.is(arrayModel.rowCount(), 1);
t.is(arrayModel.rowData(0), 1);
})
test('Timer negative duration', (t) => {
t.throws(() => {
Timer.singleShot(-1, function () { })
},
{
code: "GenericFailure",
message: "Duration cannot be negative"
}
);
})

View file

@ -182,10 +182,10 @@ export abstract class Model<T> {
* @hidden
*/
class NullPeer {
rowDataChanged(row: number): void {}
rowAdded(row: number, count: number): void {}
rowRemoved(row: number, count: number): void {}
reset(): void {}
rowDataChanged(row: number): void { }
rowAdded(row: number, count: number): void { }
rowRemoved(row: number, count: number): void { }
reset(): void { }
}
/**
@ -260,9 +260,13 @@ export class ArrayModel<T> extends Model<T> {
*/
export interface ComponentHandle {
/**
* Shows the window and runs the event loop.
* Shows the window and runs the event loop. The returned promise is resolved when the event loop
* is terminated, for example when the last window was closed, or {@link quit_event_loop} was called.
*
* This function is a convenience for calling {@link show}, followed by {@link run_event_loop}, and
* {@link hide} when the event loop's promise is resolved.
*/
run();
run(): Promise<unknown>;
/**
* Shows the component's window on the screen.
@ -294,8 +298,10 @@ class Component implements ComponentHandle {
this.instance = instance;
}
run() {
this.instance.run();
async run() {
this.show();
await run_event_loop();
this.hide();
}
show() {
@ -530,12 +536,78 @@ export function loadFile(filePath: string, options?: LoadFileOptions): Object {
return slint_module;
}
// This api will be removed after teh event loop handling is merged check PR #3718.
// After that this in no longer necessary.
export namespace Timer {
export function singleShot(duration: number, handler: () => void) {
napi.singleshotTimer(duration, handler);
class EventLoop {
#quit_loop: boolean = false;
#termination_promise: Promise<unknown> | null = null;
#terminate_resolve_fn: ((_value: unknown) => void) | null;
constructor() {
}
start(running_callback?: Function): Promise<unknown> {
if (this.#termination_promise != null) {
return this.#termination_promise;
}
this.#termination_promise = new Promise((resolve) => {
this.#terminate_resolve_fn = resolve;
});
this.#quit_loop = false;
if (running_callback != undefined) {
napi.invokeFromEventLoop(() => {
running_callback();
running_callback = undefined;
});
}
// Give the nodejs event loop 16 ms to tick. This polling is sub-optimal, but it's the best we
// can do right now.
const nodejsPollInterval = 16;
let id = setInterval(() => {
if (napi.processEvents() == napi.ProcessEventsResult.Exited || this.#quit_loop) {
clearInterval(id);
this.#terminate_resolve_fn!(undefined);
this.#terminate_resolve_fn = null;
this.#termination_promise = null;
return;
}
}, nodejsPollInterval);
return this.#termination_promise;
}
quit() {
this.#quit_loop = true;
}
}
var global_event_loop: EventLoop = new EventLoop;
/**
* Spins the Slint event loop and returns a promise that resolves when the loop terminates.
*
* If the event loop is already running, then this function returns the same promise as from
* the earlier invocation.
*
* @param running_callback Optional callback that's invoked once when the event loop is running.
* The function's return value is ignored.
*
* Note that the event loop integration with Node.js is slightly imperfect. Due to conflicting
* implementation details between Slint's and Node.js' event loop, the two loops are merged
* by spinning one after the other, at 16 millisecond intervals. This means that when the
* application is idle, it continues to consume a low amount of CPU cycles, checking if either
* event loop has any pending events.
*/
export function run_event_loop(running_callback?: Function): Promise<unknown> {
return global_event_loop.start(running_callback)
}
/**
* Stops a spinning event loop. This function returns immediately, and the promise returned
from run_event_loop() will resolve in a later tick of the nodejs event loop.
*/
export function quit_event_loop() {
global_event_loop.quit()
}
/**

View file

@ -13,6 +13,7 @@
"@swc-node/register": "^1.5.5",
"@swc/core": "^1.3.32",
"@types/node": "^20.8.6",
"@types/node-fetch": "^2.6.7",
"ava": "^5.3.0",
"jimp": "^0.22.8",
"typedoc": "^0.25.2"

View file

@ -35,11 +35,6 @@ impl JsComponentInstance {
self.inner.definition().into()
}
#[napi]
pub fn run(&self) {
self.inner.run().unwrap()
}
#[napi]
pub fn get_property(&self, env: Env, name: String) -> Result<JsUnknown> {
let value = self

View file

@ -7,8 +7,7 @@ pub use interpreter::*;
mod types;
pub use types::*;
mod timer;
pub use timer::*;
use napi::{bindgen_prelude::*, Env, JsFunction};
#[macro_use]
extern crate napi_derive;
@ -17,3 +16,42 @@ extern crate napi_derive;
pub fn mock_elapsed_time(ms: f64) {
i_slint_core::tests::slint_mock_elapsed_time(ms as _);
}
#[napi]
pub enum ProcessEventsResult {
Continue,
Exited,
}
#[napi]
pub fn process_events() -> napi::Result<ProcessEventsResult> {
i_slint_backend_selector::with_platform(|b| {
b.process_events(std::time::Duration::ZERO, i_slint_core::InternalToken)
})
.map_err(|e| napi::Error::from_reason(e.to_string()))
.and_then(|result| {
Ok(match result {
core::ops::ControlFlow::Continue(()) => ProcessEventsResult::Continue,
core::ops::ControlFlow::Break(()) => ProcessEventsResult::Exited,
})
})
}
#[napi]
pub fn invoke_from_event_loop(env: Env, callback: JsFunction) -> napi::Result<napi::JsUndefined> {
i_slint_backend_selector::with_platform(|_b| {
// Nothing to do, just make sure a backend was created
Ok(())
})
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
let function_ref = RefCountedReference::new(&env, callback)?;
let function_ref = send_wrapper::SendWrapper::new(function_ref);
i_slint_core::api::invoke_from_event_loop(move || {
let function_ref = function_ref.take();
let callback: JsFunction = function_ref.get().unwrap();
callback.call_without_args(None).ok();
})
.map_err(|e| napi::Error::from_reason(e.to_string()))
.and_then(|_| env.get_undefined())
}

View file

@ -1,27 +0,0 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-1.1 OR LicenseRef-Slint-commercial
use napi::{Env, JsFunction, Result};
use crate::RefCountedReference;
/// Starts the timer with the duration, in order for the callback to called when the timer fires. It is fired only once and then deleted.
#[napi]
pub fn singleshot_timer(env: Env, duration_in_msecs: f64, handler: JsFunction) -> Result<()> {
if duration_in_msecs < 0. {
return Err(napi::Error::from_reason("Duration cannot be negative"));
}
let duration_in_msecs = duration_in_msecs as u64;
let handler_ref = RefCountedReference::new(&env, handler)?;
i_slint_core::timers::Timer::single_shot(
std::time::Duration::from_millis(duration_in_msecs),
move || {
let callback: JsFunction = handler_ref.get().unwrap();
callback.call_without_args(None).unwrap();
},
);
Ok(())
}

View file

@ -1,6 +1,7 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "esnext"
"target": "esnext",
"esModuleInterop": true,
}
}

View file

@ -50,13 +50,13 @@ mainWindow.check_if_pair_solved = function () {
model.setRowData(tile2_index, tile2);
} else {
mainWindow.disable_tiles = true;
slint.Timer.singleShot(1000, () => {
setTimeout(() => {
mainWindow.disable_tiles = false;
tile1.image_visible = false;
model.setRowData(tile1_index, tile1);
tile2.image_visible = false;
model.setRowData(tile2_index, tile2);
})
}, 1000)
}
}

View file

@ -48,13 +48,13 @@ window.check_if_pair_solved = function () {
model.setRowData(tile2_index, tile2);
} else {
window.disable_tiles = true;
slint.Timer.singleShot(1000, () => {
setTimeout(() => {
window.disable_tiles = false;
tile1.image_visible = false;
model.setRowData(tile1_index, tile1);
tile2.image_visible = false;
model.setRowData(tile2_index, tile2);
});
}, 1000);
}
}

View file

@ -169,6 +169,32 @@ impl i_slint_core::platform::Platform for Backend {
Err("Qt platform requested but Slint is compiled without Qt support".into())
}
fn process_events(
&self,
_timeout: core::time::Duration,
_: i_slint_core::InternalToken,
) -> Result<core::ops::ControlFlow<()>, PlatformError> {
#[cfg(not(no_qt))]
{
// Schedule any timers with Qt that were set up before this event loop start.
crate::qt_window::timer_event();
use cpp::cpp;
let timeout_ms: i32 = _timeout.as_millis() as _;
let loop_was_quit = cpp! {unsafe [timeout_ms as "int"] -> bool as "bool" {
ensure_initialized(true);
qApp->processEvents(QEventLoop::AllEvents, timeout_ms);
return std::exchange(g_lastWindowClosed, false);
} };
Ok(if loop_was_quit {
core::ops::ControlFlow::Break(())
} else {
core::ops::ControlFlow::Continue(())
})
}
#[cfg(no_qt)]
Err("Qt platform requested but Slint is compiled without Qt support".into())
}
#[cfg(not(no_qt))]
fn new_event_loop_proxy(&self) -> Option<Box<dyn i_slint_core::platform::EventLoopProxy>> {
struct Proxy;

View file

@ -157,6 +157,8 @@ cpp! {{
using QPainterPtr = std::unique_ptr<QPainter>;
static bool g_lastWindowClosed = false; // Wohoo, global to track window closure when using processEvents().
/// Make sure there is an instance of QApplication.
/// The `from_qt_backend` argument specifies if we know that we are running
/// the Qt backend, or if we are just drawing widgets

View file

@ -1529,11 +1529,25 @@ impl WindowAdapter for QtWindow {
self.rendering_metrics_collector.take();
let widget_ptr = self.widget_ptr();
cpp! {unsafe [widget_ptr as "QWidget*"] {
bool wasVisible = widget_ptr->isVisible();
widget_ptr->hide();
// Since we don't call close(), this will force Qt to recompute wether there are any
// visible windows, and ends the application if needed
auto _locker = QEventLoopLocker();
// Compute the same thing also manually, when the event loop is driven by processEvents
// like in the NodeJS port.
if (wasVisible) {
auto windows = QGuiApplication::topLevelWindows();
bool visible_windows_left = std::any_of(windows.begin(), windows.end(), [](auto window) {
return window->isVisible() || window->transientParent();
});
g_lastWindowClosed = !visible_windows_left;
}
}};
Ok(())
}
}

View file

@ -665,6 +665,60 @@ impl EventLoopState {
Ok(Self::default())
}
}
/// Runs the event loop and renders the items in the provided `component` in its
/// own window.
#[cfg(not(target_arch = "wasm32"))]
pub fn pump_events(
mut self,
timeout: Option<std::time::Duration>,
) -> Result<(Self, winit::platform::pump_events::PumpStatus), corelib::platform::PlatformError>
{
use winit::platform::pump_events::EventLoopExtPumpEvents;
let not_running_loop_instance = MAYBE_LOOP_INSTANCE
.with(|loop_instance| match loop_instance.borrow_mut().take() {
Some(instance) => Ok(instance),
None => NotRunningEventLoop::new(),
})
.map_err(|e| format!("Error initializing winit event loop: {e}"))?;
let event_loop_proxy = not_running_loop_instance.event_loop_proxy;
GLOBAL_PROXY
.get_or_init(Default::default)
.lock()
.unwrap()
.set_proxy(event_loop_proxy.clone());
let mut winit_loop = not_running_loop_instance.instance;
let clipboard = not_running_loop_instance.clipboard;
let result = winit_loop.pump_events(
timeout,
|event: Event<SlintUserEvent>,
event_loop_target: &EventLoopWindowTarget<SlintUserEvent>| {
let running_instance = RunningEventLoop {
event_loop_target,
event_loop_proxy: &event_loop_proxy,
clipboard: &clipboard,
};
CURRENT_WINDOW_TARGET
.set(&running_instance, || self.process_event(event, event_loop_target))
},
);
*GLOBAL_PROXY.get_or_init(Default::default).lock().unwrap() = Default::default();
// Keep the EventLoop instance alive and re-use it in future invocations of run_event_loop().
// Winit does not support creating multiple instances of the event loop.
let nre = NotRunningEventLoop { clipboard, instance: winit_loop, event_loop_proxy };
MAYBE_LOOP_INSTANCE.with(|loop_instance| *loop_instance.borrow_mut() = Some(nre));
if let Some(error) = self.loop_error {
return Err(error);
}
Ok((self, result))
}
}
#[cfg(target_arch = "wasm32")]

View file

@ -240,6 +240,29 @@ impl i_slint_core::platform::Platform for Backend {
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn process_events(
&self,
timeout: core::time::Duration,
_: i_slint_core::InternalToken,
) -> Result<core::ops::ControlFlow<()>, PlatformError> {
let loop_state = self.event_loop_state.borrow_mut().take().unwrap_or_default();
let (new_state, status) = loop_state.pump_events(Some(timeout))?;
*self.event_loop_state.borrow_mut() = Some(new_state);
match status {
winit::platform::pump_events::PumpStatus::Continue => {
Ok(core::ops::ControlFlow::Continue(()))
}
winit::platform::pump_events::PumpStatus::Exit(code) => {
if code == 0 {
Ok(core::ops::ControlFlow::Break(()))
} else {
return Err(format!("Event loop exited with non-zero code {code}").into());
}
}
}
}
fn new_event_loop_proxy(&self) -> Option<Box<dyn EventLoopProxy>> {
struct Proxy;
impl EventLoopProxy for Proxy {

View file

@ -36,6 +36,32 @@ pub trait Platform {
Err(PlatformError::NoEventLoopProvider)
}
/// Spins an event loop for a specified period of time.
///
/// This function is similar to `run_event_loop()` with two differences:
/// * The function is expected to return after the provided timeout, but
/// allow for subsequent invocations to resume the previous loop. The
/// function can return earlier if the loop was terminated otherwise,
/// for example by `quit_event_loop()` or a last-window-closed mechanism.
/// * If the timeout is zero, the implementation should merely peek and
/// process any pending events, but then return immediately.
///
/// When the function returns `ControlFlow::Continue`, it is assumed that
/// the loop remains intact and that in the future the caller should call
/// `process_events()` again, to permit the user to continue to interact with
/// windows.
/// When the function returns `ControlFlow::Break`, it is assumed that the
/// event loop was terminated. Any subsequent calls to `process_events()`
/// will start the event loop afresh.
#[doc(hidden)]
fn process_events(
&self,
_timeout: core::time::Duration,
_: crate::InternalToken,
) -> Result<core::ops::ControlFlow<()>, PlatformError> {
Err(PlatformError::NoEventLoopProvider)
}
/// Specify if the event loop should quit quen the last window is closed.
/// The default behavior is `true`.
/// When this is set to `false`, the event loop must keep running until