Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ jobs:
CARGO_PROFILE_DEV_DEBUG: 0
CARGO_INCREMENTAL: false
RUST_BACKTRACE: full
SLINT_BACKEND: winit
strategy:
matrix:
os: [ubuntu-22.04, macos-14, windows-2022]
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
# Changelog
All notable changes to this project are documented in this file.

## [1.14.0] - TBD

- Python: Added support for asyncio by making the Slint event loop act as asyncio event loop.

## [1.13.1] - 2025-09-11

- Windows: Fixed flickering when updating the menu bar.
Expand Down
1 change: 1 addition & 0 deletions api/python/slint/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ chrono = "0.4"
spin_on = { workspace = true }
css-color-parser2 = { workspace = true }
pyo3-stub-gen = { version = "0.9.0", default-features = false }
smol = { version = "2.0.0" }

[package.metadata.maturin]
python-source = "slint"
18 changes: 17 additions & 1 deletion api/python/slint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ in detail.

## Installation

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

```bash
uv add slint
Expand Down Expand Up @@ -326,6 +326,22 @@ value = slint.loader.app.MyOption.Variant2
main_window.data = value
```

## Asynchronous I/O

Use Python's [asyncio](https://docs.python.org/3/library/asyncio.html) library to write concurrent Python code with the `async`/`await` syntax.

Slint's event loop is a full-featured [asyncio event loop](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio-event-loop). While
the event loop is running, [`asyncio.get_event_loop()`](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.get_running_loop) returns
a valid loop. To run an async function when starting the loop, pass a coroutine to `slint.run_event_loop()`.

For the common use case of interacting with REST APIs, we recommend the [`aiohttp` library](https://docs.aiohttp.org/en/stable/).

### Known Limitations

- Pipes and sub-processes are only supported on Unix-like platforms.
- Exceptions thrown in the coroutine passed to `slint.run_event_loop()` don't cause the loop to terminate. This behaviour may
change in a future release.

## Third-Party Licenses

For a list of the third-party licenses of all dependencies, see the separate [Third-Party Licenses page](thirdparty.html).
148 changes: 148 additions & 0 deletions api/python/slint/async_adapter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright © SixtyFPS GmbH <[email protected]>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0

use std::rc::Rc;

use pyo3::prelude::*;
use pyo3_stub_gen::{derive::gen_stub_pyclass, derive::gen_stub_pymethods};

#[cfg(unix)]
struct PyFdWrapper(std::os::fd::RawFd);

#[cfg(unix)]
impl std::os::fd::AsFd for PyFdWrapper {
fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {
unsafe { std::os::fd::BorrowedFd::borrow_raw(self.0) }
}
}

#[cfg(windows)]
struct PyFdWrapper(#[cfg(windows)] std::os::windows::io::RawSocket);

#[cfg(windows)]
impl std::os::windows::io::AsSocket for PyFdWrapper {
fn as_socket(&self) -> std::os::windows::io::BorrowedSocket<'_> {
unsafe { std::os::windows::io::BorrowedSocket::borrow_raw(self.0) }
}
}

struct AdapterInner {
adapter: smol::Async<PyFdWrapper>,
readable_callback: Option<Py<PyAny>>,
writable_callback: Option<Py<PyAny>>,
}

#[gen_stub_pyclass]
#[pyclass(unsendable)]
pub struct AsyncAdapter {
inner: Option<Rc<AdapterInner>>,
task: Option<slint_interpreter::JoinHandle<()>>,
}

#[gen_stub_pymethods]
#[pymethods]
impl AsyncAdapter {
#[new]
fn py_new(fd: i32) -> Self {
#[cfg(windows)]
let fd = u64::try_from(fd).unwrap();
AsyncAdapter {
inner: Some(Rc::new(AdapterInner {
adapter: smol::Async::new(PyFdWrapper(fd)).unwrap(),
readable_callback: Default::default(),
writable_callback: Default::default(),
})),
task: None,
}
}

fn wait_for_readable(&mut self, callback: Py<PyAny>) {
self.restart_after_mut_inner_access(|inner| {
inner.readable_callback.replace(callback);
});
}

fn wait_for_writable(&mut self, callback: Py<PyAny>) {
self.restart_after_mut_inner_access(|inner| {
inner.writable_callback.replace(callback);
});
}
}

impl AsyncAdapter {
fn restart_after_mut_inner_access(&mut self, callback: impl FnOnce(&mut AdapterInner)) {
if let Some(task) = self.task.take() {
task.abort();
}

// This detaches and basically makes any existing future that might get woke up fail when
// trying to upgrade the weak.
let mut inner = Rc::into_inner(self.inner.take().unwrap()).unwrap();

callback(&mut inner);

let inner = Rc::new(inner);
let inner_weak = Rc::downgrade(&inner);
self.inner = Some(inner);
self.task = Some(
slint_interpreter::spawn_local(std::future::poll_fn(move |cx| loop {
let Some(inner) = inner_weak.upgrade() else {
return std::task::Poll::Ready(());
};

let readable_poll_status: Option<std::task::Poll<Py<PyAny>>> =
inner.readable_callback.as_ref().map(|callback| {
if inner.adapter.poll_readable(cx).is_ready() {
std::task::Poll::Ready(Python::attach(|py| callback.clone_ref(py)))
} else {
std::task::Poll::Pending
}
});

let writable_poll_status: Option<std::task::Poll<Py<PyAny>>> =
inner.writable_callback.as_ref().map(|callback| {
if inner.adapter.poll_writable(cx).is_ready() {
std::task::Poll::Ready(Python::attach(|py| callback.clone_ref(py)))
} else {
std::task::Poll::Pending
}
});

let fd = inner.adapter.get_ref().0;

drop(inner);

if let Some(std::task::Poll::Ready(callback)) = &readable_poll_status {
Python::attach(|py| {
callback.call1(py, (fd,)).expect(
"unexpected failure running python async readable adapter callback",
);
});
}

if let Some(std::task::Poll::Ready(callback)) = &writable_poll_status {
Python::attach(|py| {
callback.call1(py, (fd,)).expect(
"unexpected failure running python async writable adapter callback",
);
});
}

match &readable_poll_status {
Some(std::task::Poll::Ready(..)) => continue, // poll again and then probably return in the next iteration
Some(std::task::Poll::Pending) => return std::task::Poll::Pending, // waker registered, come back later
None => {} // Nothing to poll
}

match &writable_poll_status {
Some(std::task::Poll::Ready(..)) => continue, // poll again and then probably return in the next iteration
Some(std::task::Poll::Pending) => return std::task::Poll::Pending, // waker registered, come back later
None => {} // Nothing to poll
}

return std::task::Poll::Ready(());
}))
.unwrap(),
);
}
}
4 changes: 0 additions & 4 deletions api/python/slint/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,10 +439,6 @@ impl ComponentInstance {
Ok(self.instance.hide()?)
}

fn run(&self) -> Result<(), PyPlatformError> {
Ok(self.instance.run()?)
}

fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> {
self.callbacks.__traverse__(&visit)?;
for global_callbacks in self.global_callbacks.values() {
Expand Down
21 changes: 19 additions & 2 deletions api/python/slint/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use interpreter::{
CompilationResult, Compiler, ComponentDefinition, ComponentInstance, PyDiagnostic,
PyDiagnosticLevel, PyValueType,
};
mod async_adapter;
mod brush;
mod errors;
mod models;
Expand Down Expand Up @@ -42,10 +43,11 @@ thread_local! {

#[gen_stub_pyfunction]
#[pyfunction]
fn run_event_loop() -> Result<(), PyErr> {
fn run_event_loop(py: Python<'_>) -> Result<(), PyErr> {
EVENT_LOOP_EXCEPTION.replace(None);
EVENT_LOOP_RUNNING.set(true);
let result = slint_interpreter::run_event_loop();
// Release the GIL while running the event loop, so that other Python threads can run.
let result = py.detach(|| slint_interpreter::run_event_loop());
EVENT_LOOP_RUNNING.set(false);
result.map_err(|e| errors::PyPlatformError::from(e))?;
EVENT_LOOP_EXCEPTION.take().map_or(Ok(()), |err| Err(err))
Expand All @@ -63,6 +65,19 @@ fn set_xdg_app_id(app_id: String) -> Result<(), errors::PyPlatformError> {
slint_interpreter::set_xdg_app_id(app_id).map_err(|e| e.into())
}

#[gen_stub_pyfunction]
#[pyfunction]
fn invoke_from_event_loop(callable: Py<PyAny>) -> Result<(), errors::PyEventLoopError> {
slint_interpreter::invoke_from_event_loop(move || {
Python::attach(|py| {
if let Err(err) = callable.call0(py) {
eprintln!("Error invoking python callable from closure invoked via slint::invoke_from_event_loop: {}", err)
}
})
})
.map_err(|e| e.into())
}

use pyo3::prelude::*;

#[pymodule]
Expand All @@ -87,9 +102,11 @@ fn slint(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<brush::PyBrush>()?;
m.add_class::<models::PyModelBase>()?;
m.add_class::<value::PyStruct>()?;
m.add_class::<async_adapter::AsyncAdapter>()?;
m.add_function(wrap_pyfunction!(run_event_loop, m)?)?;
m.add_function(wrap_pyfunction!(quit_event_loop, m)?)?;
m.add_function(wrap_pyfunction!(set_xdg_app_id, m)?)?;
m.add_function(wrap_pyfunction!(invoke_from_event_loop, m)?)?;

Ok(())
}
Expand Down
2 changes: 1 addition & 1 deletion api/python/slint/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
def python(session: nox.Session):
session.env["MATURIN_PEP517_ARGS"] = "--profile=dev"
session.install(".[dev]")
session.run("pytest", "-s")
session.run("pytest", "-s", "-v")
3 changes: 2 additions & 1 deletion api/python/slint/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Changelog = "https://github.com/slint-ui/slint/blob/master/CHANGELOG.md"
Tracker = "https://github.com/slint-ui/slint/issues"

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

[dependency-groups]
dev = [
Expand All @@ -48,6 +48,7 @@ dev = [
"ruff>=0.9.6",
"pillow>=11.3.0",
"numpy>=2.3.2",
"aiohttp>=3.12.15",
]

[tool.uv]
Expand Down
59 changes: 58 additions & 1 deletion api/python/slint/slint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
import pathlib
from .models import ListModel, Model
from .slint import Image, Color, Brush, Timer, TimerMode
from .loop import SlintEventLoop
from pathlib import Path
from collections.abc import Coroutine
import asyncio

Struct = native.PyStruct

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

def run(self) -> None:
"""Shows the window, runs the event loop, hides it when the loop is quit, and returns."""
self.__instance__.run()
self.show()
run_event_loop()
self.hide()


def _normalize_prop(name: str) -> str:
Expand Down Expand Up @@ -426,6 +431,56 @@ def set_xdg_app_id(app_id: str) -> None:
native.set_xdg_app_id(app_id)


quit_event = asyncio.Event()


def run_event_loop(
main_coro: typing.Optional[Coroutine[None, None, None]] = None,
) -> None:
"""Runs the main Slint event loop. If specified, the coroutine `main_coro` is run in parallel. The event loop doesn't
terminate when the coroutine finishes, it terminates when calling `quit_event_loop()`.

Example:
```python
import slint

...
image_model: slint.ListModel[slint.Image] = slint.ListModel()
...

async def main_receiver(image_model: slint.ListModel) -> None:
async with aiohttp.ClientSession() as session:
async with session.get("http://some.server/svg-image") as response:
svg = await response.read()
image = slint.Image.from_svg_data(svg)
image_model.append(image)

...
slint.run_event_loop(main_receiver(image_model))
```

"""

async def run_inner() -> None:
global quit_event
loop = typing.cast(SlintEventLoop, asyncio.get_event_loop())
if main_coro:
loop.create_task(main_coro)

await quit_event.wait()

global quit_event
quit_event = asyncio.Event()
asyncio.run(run_inner(), debug=False, loop_factory=SlintEventLoop)


def quit_event_loop() -> None:
"""Quits the running event loop in the next event processing cycle. This will make an earlier call to `run_event_loop()`
return."""
global quit_event
quit_event.set()


__all__ = [
"CompileError",
"Component",
Expand All @@ -440,4 +495,6 @@ def set_xdg_app_id(app_id: str) -> None:
"TimerMode",
"set_xdg_app_id",
"callback",
"run_event_loop",
"quit_event_loop",
]
Loading
Loading