Skip to content

Commit 57882c3

Browse files
committed
Python: Initial support for asyncio
The API is minimal at this point: `slint.run_event_loop()` takes an optional coroutine parameter, that's run. As the loop is run, an asyncio event loop (as per asyncio.get_event_loop()) is active, which maps socket operations to smol's Async adapter and polls them within the Slint event loop. cc #4137
1 parent 9a54838 commit 57882c3

File tree

13 files changed

+706
-13
lines changed

13 files changed

+706
-13
lines changed

.github/workflows/ci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ jobs:
177177
CARGO_PROFILE_DEV_DEBUG: 0
178178
CARGO_INCREMENTAL: false
179179
RUST_BACKTRACE: full
180+
SLINT_BACKEND: winit
180181
strategy:
181182
matrix:
182183
os: [ubuntu-22.04, macos-14, windows-2022]

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
# Changelog
44
All notable changes to this project are documented in this file.
55

6+
## [1.14.0] - TBD
7+
8+
- Python: Added support for asyncio by making the Slint event loop act as asyncio event loop.
9+
610
## [1.13.1] - 2025-09-11
711

812
- Windows: Fixed flickering when updating the menu bar.

api/python/slint/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ chrono = "0.4"
5252
spin_on = { workspace = true }
5353
css-color-parser2 = { workspace = true }
5454
pyo3-stub-gen = { version = "0.9.0", default-features = false }
55+
smol = { version = "2.0.0" }
5556

5657
[package.metadata.maturin]
5758
python-source = "slint"

api/python/slint/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ in detail.
2222

2323
## Installation
2424

25-
Slint can be installed with `uv` or `pip` from the [Python Package Index](https://pypi.org):
25+
Install Slint with `uv` or `pip` from the [Python Package Index](https://pypi.org):
2626

2727
```bash
2828
uv add slint
@@ -326,6 +326,22 @@ value = slint.loader.app.MyOption.Variant2
326326
main_window.data = value
327327
```
328328

329+
## Asynchronous I/O
330+
331+
Use Python's [asyncio](https://docs.python.org/3/library/asyncio.html) library to write concurrent Python code with the `async`/`await` syntax.
332+
333+
Slint's event loop is a full-featured [asyncio event loop](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio-event-loop). While
334+
the event loop is running, [`asyncio.get_event_loop()`](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.get_running_loop) returns
335+
a valid loop. To run an async function when starting the loop, pass a coroutine to `slint.run_event_loop()`.
336+
337+
For the common use case of interacting with REST APIs, we recommend the [`aiohttp` library](https://docs.aiohttp.org/en/stable/).
338+
339+
### Known Limitations
340+
341+
- Pipes and sub-processes are only supported on Unix-like platforms.
342+
- Exceptions thrown in the coroutine passed to `slint.run_event_loop()` don't cause the loop to terminate. This behaviour may
343+
change in a future release.
344+
329345
## Third-Party Licenses
330346

331347
For a list of the third-party licenses of all dependencies, see the separate [Third-Party Licenses page](thirdparty.html).

api/python/slint/async_adapter.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright © SixtyFPS GmbH <[email protected]>
2+
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3+
4+
use std::rc::Rc;
5+
6+
use pyo3::prelude::*;
7+
use pyo3_stub_gen::{derive::gen_stub_pyclass, derive::gen_stub_pymethods};
8+
9+
#[cfg(unix)]
10+
struct PyFdWrapper(std::os::fd::RawFd);
11+
12+
#[cfg(unix)]
13+
impl std::os::fd::AsFd for PyFdWrapper {
14+
fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {
15+
unsafe { std::os::fd::BorrowedFd::borrow_raw(self.0) }
16+
}
17+
}
18+
19+
#[cfg(windows)]
20+
struct PyFdWrapper(#[cfg(windows)] std::os::windows::io::RawSocket);
21+
22+
#[cfg(windows)]
23+
impl std::os::windows::io::AsSocket for PyFdWrapper {
24+
fn as_socket(&self) -> std::os::windows::io::BorrowedSocket<'_> {
25+
unsafe { std::os::windows::io::BorrowedSocket::borrow_raw(self.0) }
26+
}
27+
}
28+
29+
struct AdapterInner {
30+
adapter: smol::Async<PyFdWrapper>,
31+
readable_callback: Option<Py<PyAny>>,
32+
writable_callback: Option<Py<PyAny>>,
33+
}
34+
35+
#[gen_stub_pyclass]
36+
#[pyclass(unsendable)]
37+
pub struct AsyncAdapter {
38+
inner: Option<Rc<AdapterInner>>,
39+
task: Option<slint_interpreter::JoinHandle<()>>,
40+
}
41+
42+
#[gen_stub_pymethods]
43+
#[pymethods]
44+
impl AsyncAdapter {
45+
#[new]
46+
fn py_new(fd: i32) -> Self {
47+
#[cfg(windows)]
48+
let fd = u64::try_from(fd).unwrap();
49+
AsyncAdapter {
50+
inner: Some(Rc::new(AdapterInner {
51+
adapter: smol::Async::new(PyFdWrapper(fd)).unwrap(),
52+
readable_callback: Default::default(),
53+
writable_callback: Default::default(),
54+
})),
55+
task: None,
56+
}
57+
}
58+
59+
fn wait_for_readable(&mut self, callback: Py<PyAny>) {
60+
self.restart_after_mut_inner_access(|inner| {
61+
inner.readable_callback.replace(callback);
62+
});
63+
}
64+
65+
fn wait_for_writable(&mut self, callback: Py<PyAny>) {
66+
self.restart_after_mut_inner_access(|inner| {
67+
inner.writable_callback.replace(callback);
68+
});
69+
}
70+
}
71+
72+
impl AsyncAdapter {
73+
fn restart_after_mut_inner_access(&mut self, callback: impl FnOnce(&mut AdapterInner)) {
74+
if let Some(task) = self.task.take() {
75+
task.abort();
76+
}
77+
78+
// This detaches and basically makes any existing future that might get woke up fail when
79+
// trying to upgrade the weak.
80+
let mut inner = Rc::into_inner(self.inner.take().unwrap()).unwrap();
81+
82+
callback(&mut inner);
83+
84+
let inner = Rc::new(inner);
85+
let inner_weak = Rc::downgrade(&inner);
86+
self.inner = Some(inner);
87+
self.task = Some(
88+
slint_interpreter::spawn_local(std::future::poll_fn(move |cx| loop {
89+
let Some(inner) = inner_weak.upgrade() else {
90+
return std::task::Poll::Ready(());
91+
};
92+
93+
let readable_poll_status: Option<std::task::Poll<Py<PyAny>>> =
94+
inner.readable_callback.as_ref().map(|callback| {
95+
if inner.adapter.poll_readable(cx).is_ready() {
96+
std::task::Poll::Ready(Python::attach(|py| callback.clone_ref(py)))
97+
} else {
98+
std::task::Poll::Pending
99+
}
100+
});
101+
102+
let writable_poll_status: Option<std::task::Poll<Py<PyAny>>> =
103+
inner.writable_callback.as_ref().map(|callback| {
104+
if inner.adapter.poll_writable(cx).is_ready() {
105+
std::task::Poll::Ready(Python::attach(|py| callback.clone_ref(py)))
106+
} else {
107+
std::task::Poll::Pending
108+
}
109+
});
110+
111+
let fd = inner.adapter.get_ref().0;
112+
113+
drop(inner);
114+
115+
if let Some(std::task::Poll::Ready(callback)) = &readable_poll_status {
116+
Python::attach(|py| {
117+
callback.call1(py, (fd,)).expect(
118+
"unexpected failure running python async readable adapter callback",
119+
);
120+
});
121+
}
122+
123+
if let Some(std::task::Poll::Ready(callback)) = &writable_poll_status {
124+
Python::attach(|py| {
125+
callback.call1(py, (fd,)).expect(
126+
"unexpected failure running python async writable adapter callback",
127+
);
128+
});
129+
}
130+
131+
match &readable_poll_status {
132+
Some(std::task::Poll::Ready(..)) => continue, // poll again and then probably return in the next iteration
133+
Some(std::task::Poll::Pending) => return std::task::Poll::Pending, // waker registered, come back later
134+
None => {} // Nothing to poll
135+
}
136+
137+
match &writable_poll_status {
138+
Some(std::task::Poll::Ready(..)) => continue, // poll again and then probably return in the next iteration
139+
Some(std::task::Poll::Pending) => return std::task::Poll::Pending, // waker registered, come back later
140+
None => {} // Nothing to poll
141+
}
142+
143+
return std::task::Poll::Ready(());
144+
}))
145+
.unwrap(),
146+
);
147+
}
148+
}

api/python/slint/interpreter.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -439,10 +439,6 @@ impl ComponentInstance {
439439
Ok(self.instance.hide()?)
440440
}
441441

442-
fn run(&self) -> Result<(), PyPlatformError> {
443-
Ok(self.instance.run()?)
444-
}
445-
446442
fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> {
447443
self.callbacks.__traverse__(&visit)?;
448444
for global_callbacks in self.global_callbacks.values() {

api/python/slint/lib.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use interpreter::{
1111
CompilationResult, Compiler, ComponentDefinition, ComponentInstance, PyDiagnostic,
1212
PyDiagnosticLevel, PyValueType,
1313
};
14+
mod async_adapter;
1415
mod brush;
1516
mod errors;
1617
mod models;
@@ -42,10 +43,11 @@ thread_local! {
4243

4344
#[gen_stub_pyfunction]
4445
#[pyfunction]
45-
fn run_event_loop() -> Result<(), PyErr> {
46+
fn run_event_loop(py: Python<'_>) -> Result<(), PyErr> {
4647
EVENT_LOOP_EXCEPTION.replace(None);
4748
EVENT_LOOP_RUNNING.set(true);
48-
let result = slint_interpreter::run_event_loop();
49+
// Release the GIL while running the event loop, so that other Python threads can run.
50+
let result = py.detach(|| slint_interpreter::run_event_loop());
4951
EVENT_LOOP_RUNNING.set(false);
5052
result.map_err(|e| errors::PyPlatformError::from(e))?;
5153
EVENT_LOOP_EXCEPTION.take().map_or(Ok(()), |err| Err(err))
@@ -100,6 +102,7 @@ fn slint(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
100102
m.add_class::<brush::PyBrush>()?;
101103
m.add_class::<models::PyModelBase>()?;
102104
m.add_class::<value::PyStruct>()?;
105+
m.add_class::<async_adapter::AsyncAdapter>()?;
103106
m.add_function(wrap_pyfunction!(run_event_loop, m)?)?;
104107
m.add_function(wrap_pyfunction!(quit_event_loop, m)?)?;
105108
m.add_function(wrap_pyfunction!(set_xdg_app_id, m)?)?;

api/python/slint/noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
def python(session: nox.Session):
99
session.env["MATURIN_PEP517_ARGS"] = "--profile=dev"
1010
session.install(".[dev]")
11-
session.run("pytest", "-s")
11+
session.run("pytest", "-s", "-v")

api/python/slint/pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Changelog = "https://github.com/slint-ui/slint/blob/master/CHANGELOG.md"
3737
Tracker = "https://github.com/slint-ui/slint/issues"
3838

3939
[project.optional-dependencies]
40-
dev = ["pytest", "numpy>=2.3.2", "pillow>=11.3.0"]
40+
dev = ["pytest", "numpy>=2.3.2", "pillow>=11.3.0", "aiohttp>=3.12.15"]
4141

4242
[dependency-groups]
4343
dev = [
@@ -48,13 +48,14 @@ dev = [
4848
"ruff>=0.9.6",
4949
"pillow>=11.3.0",
5050
"numpy>=2.3.2",
51+
"aiohttp>=3.12.15",
5152
]
5253

5354
[tool.uv]
5455
# Rebuild package when any rust files change
5556
cache-keys = [{ file = "pyproject.toml" }, { file = "Cargo.toml" }, { file = "**/*.rs" }]
5657
# Uncomment to build rust code in development mode
57-
# config-settings = { build-args = '--profile=dev' }
58+
config-settings = { build-args = '--profile=dev' }
5859

5960
[tool.mypy]
6061
strict = true

api/python/slint/slint/__init__.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
import pathlib
1717
from .models import ListModel, Model
1818
from .slint import Image, Color, Brush, Timer, TimerMode
19+
from .loop import SlintEventLoop
1920
from pathlib import Path
21+
from collections.abc import Coroutine
22+
import asyncio
2023

2124
Struct = native.PyStruct
2225

@@ -52,7 +55,9 @@ def hide(self) -> None:
5255

5356
def run(self) -> None:
5457
"""Shows the window, runs the event loop, hides it when the loop is quit, and returns."""
55-
self.__instance__.run()
58+
self.show()
59+
run_event_loop()
60+
self.hide()
5661

5762

5863
def _normalize_prop(name: str) -> str:
@@ -426,6 +431,56 @@ def set_xdg_app_id(app_id: str) -> None:
426431
native.set_xdg_app_id(app_id)
427432

428433

434+
quit_event = asyncio.Event()
435+
436+
437+
def run_event_loop(
438+
main_coro: typing.Optional[Coroutine[None, None, None]] = None,
439+
) -> None:
440+
"""Runs the main Slint event loop. If specified, the coroutine `main_coro` is run in parallel. The event loop doesn't
441+
terminate when the coroutine finishes, it terminates when calling `quit_event_loop()`.
442+
443+
Example:
444+
```python
445+
import slint
446+
447+
...
448+
image_model: slint.ListModel[slint.Image] = slint.ListModel()
449+
...
450+
451+
async def main_receiver(image_model: slint.ListModel) -> None:
452+
async with aiohttp.ClientSession() as session:
453+
async with session.get("http://some.server/svg-image") as response:
454+
svg = await response.read()
455+
image = slint.Image.from_svg_data(svg)
456+
image_model.append(image)
457+
458+
...
459+
slint.run_event_loop(main_receiver(image_model))
460+
```
461+
462+
"""
463+
464+
async def run_inner() -> None:
465+
global quit_event
466+
loop = typing.cast(SlintEventLoop, asyncio.get_event_loop())
467+
if main_coro:
468+
loop.create_task(main_coro)
469+
470+
await quit_event.wait()
471+
472+
global quit_event
473+
quit_event = asyncio.Event()
474+
asyncio.run(run_inner(), debug=False, loop_factory=SlintEventLoop)
475+
476+
477+
def quit_event_loop() -> None:
478+
"""Quits the running event loop in the next event processing cycle. This will make an earlier call to `run_event_loop()`
479+
return."""
480+
global quit_event
481+
quit_event.set()
482+
483+
429484
__all__ = [
430485
"CompileError",
431486
"Component",
@@ -440,4 +495,6 @@ def set_xdg_app_id(app_id: str) -> None:
440495
"TimerMode",
441496
"set_xdg_app_id",
442497
"callback",
498+
"run_event_loop",
499+
"quit_event_loop",
443500
]

0 commit comments

Comments
 (0)