Adding GUI elements to Visula in Python
Visula is a visualization library I’ve been working on. It is my little playground for creating performant visualizations.
I recently added Python bindings for Visula, but these have been lacking the ability to create any GUI elements.
In the Rust version, I have been relying on the egui library for GUI elements. egui is a great library for immediate mode GUIs, which are often well suited for visualization and simulation projects. egui also integrates well with wgpu, which is the underlying graphics library of Visula. So I figured it was a good fit.
However, getting egui to work in a Python wrapper turned out to be a challenge. This is due to the closure-based style used to define GUIs in egui. For instance, the following defines a header in egui that can be collapsed to hide its child elements:
ui.collapsing_header("Heading", |ui: &mut Ui| {
ui.button("Button");
});
When I tried to wrap such code in a Python library using PyO3,
I quickly ran into issues with the borrow checker.
The mutable reference to ui
in the closure cannot easily be passed on to Python
to add more elements.
The problem is that I cannot expose an object holding a reference to the mutable Ui
Python, since in Python a developer may decide to hold on to that object indefinitely.
This in turn conflicts with the concept of a temporary reference.
There is an issue from 2021 requesting an alternative API more suited for libraries that want to wrap egui, but thus far there is no official solution to the problem.
In the meantime, I have found that it is best to expose egui in a non-immediate way. I figured I could instead make new types in PyO3 that I can construct in Python and pass back to Rust.
Here is for instance the definition of a slider in Rust with PyO3:
#[pyclass]
#[derive(Clone, Debug)]
struct PySlider {
#[pyo3(get, set)]
name: String,
#[pyo3(get, set)]
value: f32,
#[pyo3(get, set)]
minimum: f32,
#[pyo3(get, set)]
maximum: f32,
#[pyo3(get, set)]
step: f32,
}
This is exposed as usual in PyO3:
#[pymodule]
#[pyo3(name = "_visula_pyo3")]
fn visula_pyo3(_py: Python, m: &Bound<PyModule>) -> PyResult<()> {
// ...
m.add_class::<PySlider>()?;
Ok(())
}
Which enables users to create it in Python:
from visula import Slider
radius = Slider(
name="radius",
value=0.0,
minimum=0.1,
maximum=10.0,
step=0.1,
)
Finally, all such sliders are passed back to Rust when showing a figure:
from visula import Figure
fig = Figure()
fig.show(..., controls=[radius])
Then, when rendering the GUI, I iterate through all UI controls and invoke the relevant egui code to render them:
for slider in &mut sliders {
let mut slider_mut = slider.borrow_mut();
let minimum = slider_mut.minimum;
let maximum = slider_mut.maximum;
ui.label(&slider_mut.name);
ui.add(Slider::new(
&mut slider_mut.value,
minimum..=maximum,
));
}
For now, this is enough to make dynamic examples also in Python. However, it does not really allow for designing any UI in a flexible way.
The resulting UI looks something like this in a simulation:
In the future, I will consider exposing more of egui’s widgets in Python and perhaps expose a Python API for egui overall. I will see if I can find a clever way of automating that process instead of adding widget manually.