Expand description
Slint on Microcontrollers
The following sections explain how to use Slint to develop a UI on a Microcontroller (MCU) in a bare metal environment.
Prerequisites
Writing an application in Rust that runs on a MCU requires several prerequisites:
- Install a Rust toolchain to cross-compile to the target architecture.
- Locate and select the correct Hardware Abstraction Layer (HAL) crates and drivers, and depend on them in your
Cargo.toml
. - Install tools for flashing and debugging your code on the device.
We recommend reading the Rust Embedded Book, and the curated list of Awesome Embedded Rust for a wide range of crates, tools, and training materials. These resources should guide you through the initial setup. Many include a āhello worldā example to get started with your device.
Slint requires a global memory allocator in a bare metal environment with #![no_std]
.
The following sections assume that your setup is complete and you have a non-graphical skeleton Rust program running on your MCU.
Changes to Cargo.toml
Start by adding a dependency to the slint
and the slint-build
crates to your Cargo.toml
using the cargo
command:
Start with the slint
crate like this:
cargo add slint@1.1.0 --no-default-features --features "compat-1-0 unsafe-single-threaded libm"
The default features of the slint
crate are tailored towards hosted environments and includes the āstdā feature. In bare metal environments,
you need to disable the default features.
In the snippet above, three features are selected:
compat-1-0
: We select this feature when disabling the default features. For a detailed explanation see our blog post āAdding default cargo features without breaking Semantic Versioningā.unsafe-single-threaded
: Slint internally uses Rustāsthread_local!
macro to store global data. This macro is only available in the Rust Standard Library (std), but not in bare metal environments. As a fallback, theunsafe-single-threaded
feature changes Slint to use unsafe static for storage. This way, you guarantee to use Slint API only from a single thread, and not from interrupt handlers.libm
: We select this feature to enable the use of the libm crate to provide traits and functions for floating point arithmetic. Theyāre typically provided by the Rust Standard Library (std), but thatās not available in bare metal environments.
It might be necessary to enable the Feature resolver version 2
in your Cargo.toml if you notice that your dependencies are attempting to build with std
support even when disabled.
This is the default when using the Rust 2021 Edition, but not if you use a workspace.
Then add the slint-build
crate as a build dependency:
cargo add --build slint-build@1.1.0
For reference: These are the relevant parts of your Cargo.toml
file,
ready to use Slint:
[package]
## ...
## Edition 2021 or later enables the feature resolver version 2.
edition = "2021"
[dependencies]
## ... your other dependencies
[dependencies.slint]
version = "1.1.0"
default-features = false
features = ["compat-1-0", "unsafe-single-threaded", "libm"]
[build-dependencies]
slint-build = "1.1.0"
Changes to build.rs
Next, write a build script to compile the .slint
files to Rust code for embedding into the program binary, using the slint-build
crate:
fn main() {
slint_build::compile_with_config(
"ui/main.slint",
slint_build::CompilerConfiguration::new()
.embed_resources(slint_build::EmbedResourcesKind::EmbedForSoftwareRenderer),
).unwrap();
}
Use the slint_build::EmbedResourcesKind::EmbedForSoftwareRenderer
configuration option to tell the Slint compiler to embed the images and fonts in the binary
in a format thatās suitable for the software based renderer weāre going to use.
Application Structure
Typically, a graphical application in hosted environments has at least three different tasks:
- Receives user input from operation system APIs.
- Reacts to the input by performing application specific computations.
- Renders an updated user interface and presents it on the screen using device-independent operating system APIs.
The operating system provides an event loop to connect and schedule these tasks. Slint implements the task of receiving user input and forwarding it to the user interface layer, and rendering the user interface to the screen.
In bare metal environments itās your responsibility to substitute and connect functionality thatās otherwise provided by the operating system:
- Select crates that allow you to initialize the chips that operate peripherals, such as a touch input or display controller. If there are no crates, you may have to to develop your own drivers.
- Drive the event loop yourself by querying peripherals for input, forwarding that input into computational modules of your application and instructing Slint to render the user interface.
In Slint, the two primary APIs you need to use to accomplish these tasks are the slint::platform::Platform
trait and the slint::Window
struct.
In the following sections weāre going to cover how to use them and how they integrate into your event loop.
The Platform
Trait
The slint::platform::Platform
trait defines the interface between Slint and platform APIs typically provided by operating and windowing systems.
You need to provide a minimal implementation of this trait and call slint::platform::set_platform
before constructing your Slint application.
This minimal implementation needs to cover two functions:
fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter + 'static>, PlatformError>;
: Implement this function to return an implementation of theWindowAdapter
trait that will be associated with the Slint components you create. We provide a convenience structslint::platform::software_renderer::MinimalSoftwareWindow
that implements this trait.fn duration_since_start(&self) -> Duration
: For animations in.slint
design files to change properties correctly, Slint needs to know how much time has elapsed between two rendered frames. In a bare metal environment you need to provide a source of time. Often the HAL crate of your device provides a system timer API for this, which you can query in your impementation.
You may override more functions of this trait, for example to handle debug output, to delegate the event loop, or to deliver events in multi-threaded environments.
A typical minimal implementation of the Platform
trait that uses the MinimalSoftwareWindow
looks like this:
#![no_std]
extern crate alloc;
use alloc::{rc::Rc, boxed::Box};
use slint::platform::{Platform, software_renderer::MinimalSoftwareWindow};
slint::include_modules!();
struct MyPlatform {
window: Rc<MinimalSoftwareWindow>,
// optional: some timer device from your device's HAL crate
timer: hal::Timer,
// ... maybe more devices
}
impl Platform for MyPlatform {
fn create_window_adapter(&self) -> Result<Rc<dyn slint::platform::WindowAdapter>, slint::PlatformError> {
// Since on MCUs, there can be only one window, just return a clone of self.window.
// We'll also use the same window in the event loop.
Ok(self.window.clone())
}
fn duration_since_start(&self) -> core::time::Duration {
core::time::Duration::from_micros(self.timer.get_time())
}
// optional: You can put the event loop there, or in the main function, see later
fn run_event_loop(&self) -> Result<(), slint::PlatformError> {
todo!();
}
}
// #[hal::entry]
fn main() {
// Initialize the heap allocator, peripheral devices and other things.
// ...
// Initialize a window (we'll need it later).
let window = MinimalSoftwareWindow::new(Default::default());
slint::platform::set_platform(Box::new(MyPlatform {
window: window.clone(),
timer: hal::Timer(/*...*/),
//...
}))
.unwrap();
// Setup the UI.
let ui = MyUI::new();
// ... setup callback and properties on `ui` ...
// Make sure the window covers our entire screen.
window.set_size(slint::PhysicalSize::new(320, 240));
// ... start event loop (see later) ...
}
The Event Loop
With a Platform
in place, you can write the main event loop to drive all the different tasks.
You can choose between two options:
- You can implement
slint::platform::Platform::run_event_loop
: Use this if you want to start the event loop in a way similar to desktop platforms, using therun()
function of your component, or useslint::run_event_loop()
. Both of these functions will call your implementation ofslint::platform::Platform::run_event_loop
. - Implement a
loop { ... }
directly in your main function: This is called a super loop architecture and common for programs running in bare metal environments on MCUs. It allows you to initialize you device peripherals and access them without the need to move them into yourPlatform
implementation.
A typical super loop with Slint combines the tasks of querying input drivers, application specific computations, rendering and possibly putting the device into a low-power sleep state. Below is an example:
use slint::platform::software_renderer::MinimalSoftwareWindow;
let window = MinimalSoftwareWindow::new(Default::default());
//...
loop {
// Let Slint run the timer hooks and update animations.
slint::platform::update_timers_and_animations();
// Check the touch screen or input device using your driver.
if let Some(event) = check_for_touch_event(/*...*/) {
// convert the event from the driver into a `slint::platform::WindowEvent`
// and pass it to the window.
window.dispatch_event(event);
}
// ... maybe some more application logic ...
// Draw the scene if something needs to be drawn.
window.draw_if_needed(|renderer| {
// see next section about rendering.
todo!()
});
// Try to put the MCU to sleep
if !window.has_active_animations() {
if let Some(duration) = slint::platform::duration_until_next_timer_update() {
// ... schedule aĀ timer interrupt in `duration` ...
}
hal::wfi(); // Wait for interrupt
}
}
The Renderer
In desktop and embedded environments, Slint typically uses operating system provided APIs to render the user interface using the GPU. In contrast, most MCUs donāt have GPUs. Instead, software rendering is used where all rendering is done by software on the CPU. Slint provides a SoftwareRenderer for this task.
In the earlier example, weāve instantiated a slint::platform::software_renderer::MinimalSoftwareWindow
. This struct implements the
slint::platform::WindowAdapter
trait and also holds an instance of a slint::platform::software_renderer::SoftwareRenderer
. You access it
through the callback parameter of the draw_if_needed()
function.
Depending on the amount of RAM your MCU has, and the kind of screen attached, you can choose between two different ways of using the renderer:
- Use the
SoftwareRenderer::render()
function if you have enough RAM to allocate one, or even two, copies of the entire screen (also known as frame buffer). - Use the
SoftwareRenderer::render_by_line()
function to render the entire user interface line by line and send each line of pixels to the screen, typically via the SPI. This requires allocating at least enough RAM to store one single line of pixels.
With both methods Slint renders into a provided buffer, which is a slice of a type that implements the slint::platform::software_renderer::TargetPixel
trait.
For convenience, Slint provides an implementation for slint::Rgb8Pixel
and slint::platform::software_renderer::Rgb565Pixel
.
Rendering Into a Buffer
The following example uses double buffering and swaps between two buffers. This requires a graphics driver that takes the address of the currently displayed frame buffer, also known as front buffer. A dedicated chip is then responsible for reading from RAM and transferring the contents to the attached screen, without any interference of the CPU. Meanwhile, Slint renders into the second buffer, the back buffer.
use slint::platform::software_renderer::Rgb565Pixel;
// In this example, we have two buffer: one is currently displayed, and we are
// rendering into the second one. Hence we use `RepaintBufferType::SwappedBuffers`
let window = slint::platform::software_renderer::MinimalSoftwareWindow::new(
slint::platform::software_renderer::RepaintBufferType::SwappedBuffers
);
const DISPLAY_WIDTH: usize = 320;
const DISPLAY_HEIGHT: usize = 240;
let mut buffer1 = [Rgb565Pixel(0); DISPLAY_WIDTH * DISPLAY_HEIGHT];
let mut buffer2 = [Rgb565Pixel(0); DISPLAY_WIDTH * DISPLAY_HEIGHT];
// ... configure the screen driver to use buffer1 or buffer2 ...
// ... rest of initialization ...
let mut currently_displayed_buffer : &mut [_] = &mut buffer1;
let mut work_buffer : &mut [_] = &mut buffer2;
loop {
// ...
// Draw the scene if something needs to be drawn
window.draw_if_needed(|renderer| {
// The screen driver might be taking some time to do the swap. We need to wait until
// work_buffer is ready to be written in
while is_swap_pending() {}
// Do the rendering!
renderer.render(work_buffer, DISPLAY_WIDTH);
// tell the screen driver to display the other buffer.
swap_buffers();
// Swap the buffer references for our next iteration
// (this just swap the reference, not the actual data)
core::mem::swap::<&mut [_]>(&mut work_buffer, &mut currently_displayed_buffer);
});
// ...
}
Rendering Line by Line
When rendering the user interface line by line, you need to implement the LineBufferProvider
trait. It
defines a bi-directional interface between Slint and your code to send lines to the screen:
- The traitās associated
TargetPixel
type letās Slint know how to create and manipulate pixels. How exactly the pixels are represented in your device and how they are blended remains your implementation detail. - The traitās
process_line
function notifies you when a line can be rendered and provides a callback that you can invoke to fill a slice of pixels for the given line.
The following example defines a DisplayWrapper
struct: It connects screen driver that implements the embedded_graphics
traits
with Slintās Rgb565Pixel
type to implement the LineBufferProvider
trait. The pixels for one line are sent to the screen by calling
the DrawTarget::fill_contiguous function.
use embedded_graphics_core::{prelude::*, primitives::Rectangle, pixelcolor::raw::RawU16};
struct DisplayWrapper<'a, T>{
display: &'a mut T,
line_buffer: &'a mut [slint::platform::software_renderer::Rgb565Pixel],
}
impl<T: DrawTarget<Color = embedded_graphics_core::pixelcolor::Rgb565>>
slint::platform::software_renderer::LineBufferProvider for DisplayWrapper<'_, T>
{
type TargetPixel = slint::platform::software_renderer::Rgb565Pixel;
fn process_line(
&mut self,
line: usize,
range: core::ops::Range<usize>,
render_fn: impl FnOnce(&mut [Self::TargetPixel]),
) {
// Render into the line
render_fn(&mut self.line_buffer[range.clone()]);
// Send the line to the screen using DrawTarget::fill_contiguous
self.display.fill_contiguous(
&Rectangle::new(Point::new(range.start as _, line as _), Size::new(range.len() as _, 1)),
self.line_buffer[range.clone()].iter().map(|p| RawU16::new(p.0).into())
).map_err(drop).unwrap();
}
}
// Note that we use `ReusedBuffer` as parameter for MinimalSoftwareWindow to indicate
// that we just need to re-render what changed since the last frame.
// What's shown on the screen buffer is not in our RAM, but actually within the display itself.
// Only the changed part of the screen will be updated.
let window = slint::platform::software_renderer::MinimalSoftwareWindow::new(
slint::platform::software_renderer::RepaintBufferType::ReusedBuffer
);
const DISPLAY_WIDTH: usize = 320;
let mut line_buffer = [slint::platform::software_renderer::Rgb565Pixel(0); DISPLAY_WIDTH];
let mut display = hal::Display::new(/*...*/);
// ... rest of initialization ...
loop {
// ...
window.draw_if_needed(|renderer| {
renderer.render_by_line(DisplayWrapper{
display: &mut display,
line_buffer: &mut line_buffer
});
});
// ...
}
Note: In our experience, using the synchronous DrawTarget::fill_contiguous
function is slow. If
your device is capable of using DMA, you may be able to achieve better performance by using
two line buffers: One buffer to render into with the CPU, while the other buffer is transferred to
the screen using DMA asynchronously.
Example Implementations
The examples that come with Slint use a helper crate called mcu-board-support
. It provides implementations of
the Platform
trait for some MCUs, along with support for touch input and system timers.
You can find the crate in our Git repository at:
https://github.com/slint-ui/slint/tree/master/examples/mcu-board-support
If your MCU is among the supported boards, then you can use it by specifying it as a
dependency from our Git repository
in your Cargo.toml
.
For an entire template, check out our Slint Bare Metal Microcontroller Rust Template.
We also have a version of our printer demo that weāve adapted to small screens, the MCU Printer Demo.