# Slint on Microcontrolers (MCU) This document explain how to use slint to develop a UI on a MCU. ## Install toolchain / hal Each MCU or board needs the proper toolchain for cross compilation, has its own hal crate (Hardware Abstraction Layer) and drivers, and other tools to flash and debug the device. This is out of scope for this document. You can check the [Rust Embedded Book](https://docs.rust-embedded.org/book/) or other ressources specific to your device that will guide you to get a "hello world" working on your device. You will need nightly Rust, since stable Rust unfortunately doesn't provide a way to use a global allocator in a `#![no_std]` project. (untill [#51540](https://github.com/rust-lang/rust/issues/51540) or [#66741](https://github.com/rust-lang/rust/issues/66741) is stabilized) ## Set the feature flags A typical line in Cargo.toml looks like that: ```toml [dependencies] slint = { version = "0.2.6", default-features = false, features = ["compat-0-2-0", "unsafe-single-threaded", "libm", "renderer-software"] } # ... other stuf ``` Slint uses the standard library by default, so we need to disable the default features. Then you need the `compat-0-2-0` feature ([see why in this blog post](https://slint-ui.com/blog/rust-adding-default-cargo-feature.html)) As we don't have `std`, you will also need to enable the `unsafe-single-threaded` feature: Slint can't use `thread_local!` and will use unsafe static instead. By setting this feature, you promise not to use Slint API from a different thread or interrupt handler. You will also need the `libm` feature to for the math operation that would otherwise be taken care by the std lib. And the additional feature you need is `renderer-software` to enable the software renderer we will need to render a Slint scene on MCU. ## `build.rs` When targetting MCU, you will need a build script with to compile the `.slint` files using the `slint-build` crate. You will have to use the `slint_build::EmbedResourcesKind::EmbedForSoftwareRenderer` config option to tell the slint compiler to embedd the images and font in the binary in the proper format. ```rust,no_run fn main() { slint_build::compile_with_config( "ui/main.slint", slint_build::CompilerConfiguration::new() .embed_resources(slint_build::EmbedResourcesKind::EmbedForSoftwareRenderer), ).unwrap(); } ``` ## The `Platform` trait The idea is to call `[slint::platform::set_platform]` before constructing your Slint application. The [`Platform`] trait has two main responsabilities: 1. Give a window that will be used when creating your component with `new()` 2. Be a source of time. Since on bare metal, we don't have [`std::time::Instant`] as a source of time, so you need to provide the time from some time source that will likely be provided from the hal crate of your decive. Optionally, you can also use the Platform trait to run the event loop. A typical platfrom looks like this: ```rust,no_run #![no_std] extern crate alloc; use alloc::{rc::Rc, boxed::Box}; # mod hal { pub struct Timer(); impl Timer { pub fn get_time(&self) -> u64 { todo!() } } } use slint::platform::{Platform, swrenderer::MinimalSoftwareWindow}; # slint::slint!{ export MyUI := Window {} } /* slint::include_modules!(); # */ struct MyPlatform { window: Rc>, // 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) -> Rc { // Since on MCU, there can be only one window, just return a clone of self.window. // We'll also use the same window in the event loop 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) { todo!(); } } // #[hal::entry] fn main() { // init the allocator, and other devices stuff //... // init a window (we'll need it later) let window = MinimalSoftwareWindow::new(); 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 cover our all screen window.set_size(slint::PhysicalSize::new(320, 240)); // ... start event loop (see later) ... } ``` ## The event loop Once you have initialized your Platform, you can start the event loop. You've got two choices: 1. Implement [`slint::platform::Platform::run_event_loop`]. In this case, you can start the event loop in a similar way than on desktop platform using the [`run()`](slint::ComponentHandle::run) function of your component, or use [`slint::run_event_loop()`]. 2. Use a `loop { ... }` dirrectly in your main function. The second option might be more convinient on MCUs because you can initialize all the devices in your main function and you can access them in there without moving them in your Platform implementation. In our examples, we use the first option so we can use a different Platform with the same code to run on different devices. A typical eventloop looks like this: ```rust,no_run use slint::platform::{swrenderer::MinimalSoftwareWindow}; let window = MinimalSoftwareWindow::<0>::new(); # fn check_for_touch_event() -> Option { todo!() } # mod hal { pub fn wfi() {} } //... 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::WindowEvent` // and pass it to the window window.dispatch_event(event); } // Draw the scene if something needs to be drawn window.draw_if_needed(|renderer| { // see next section todo!() }); // ... maybe some more application logic ... // Put the MCU to sleep if !window.has_active_animations() { if let Some(duration) = slint::platform::duration_until_next_timer_update() { // ... schedule an interrupt in `duration` ... } hal::wfi(); // Wait for interupt } } ``` ## The renderer On MCU, we currently only support software rendering. In the previous example, we've instentiated a [`slint::platform::swrenderer::MinimalSoftwareWindow`]. This will give us an instance of the [`slint::platform::swrenderer::SoftwareRenderer`] through the [`draw_if_needed()`](MinimalSoftwareWindow::draw_if_needed) function. There are two ways to render, depending on the kind of screen and the amount of RAM. If you have enough RAM to hold one, or even two, frame buffer, you can use the [`SoftwareRenderer::render()`] function. Otherwise, if you can't hold a frame buffer in memory, you can render line by line and send these line of pixel to the screen (typically via SPI). In that case, you would use the [`SoftwareRenderer::render_by_line()`] function. Either way, you would render to a buffer (a full, or just a line), which is a slice of pixel. So a slice of something that implement the [`slint::platform::swrenderer::TargetPixel`] trait. By default, this trait is implemented for [`slint::Rgb8Pixel`] and [`slint::platform::swrenderer::Rgb565Pixel`]. ### Rendering in a buffer In this example, we'll use double buffering and swap between the buffer. ```rust,no_run use slint::platform::swrenderer::Rgb565Pixel; # fn is_swap_pending()->bool {false} fn swap_buffers() {} // Note that we use `2` as the const generic parameter which is our buffer count, // since we have two buffer, we always need to refresh what changed in the two // previous frames let window = slint::platform::swrenderer::MinimalSoftwareWindow::<2>::new(); 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); }); // ... } ``` ### Render line by line The line by line provider works by implementing the [`LineBufferProvider`] trait. This example use a screen driver that implements the [`embedded_graphics`](https://lib.rs/embedded-graphics) traits by using the [DrawTarget::fill_contiguous](https://docs.rs/embedded-graphics/0.7.1/embedded_graphics/draw_target/trait.DrawTarget.html) function ```rust,no_run use embedded_graphics_core::{prelude::*, primitives::Rectangle, pixelcolor::raw::RawU16}; # mod embedded_graphics_core { # pub mod prelude { # pub struct Point; impl Point { pub fn new(_:i32, _:i32) -> Self {todo!()} } # pub struct Size; impl Size { pub fn new(_:i32, _:i32) -> Self {todo!()} } # pub trait DrawTarget { type Color; fn fill_contiguous(&mut self, _: &super::primitives::Rectangle, _: impl IntoIterator) -> Result<(), ()> {Ok(())} } # } # pub mod primitives { pub struct Rectangle; impl Rectangle { pub fn new(_: super::prelude::Point, _: super::prelude::Size) -> Self { todo!() } } } # pub mod pixelcolor { # pub struct Rgb565; # pub mod raw { pub struct RawU16(); impl RawU16 { pub fn new(_:u16) -> Self {todo!()} } impl From for super::Rgb565 { fn from(_: RawU16) -> Self {todo!()} } } # } # } # mod hal { pub struct Display; impl Display { pub fn new()-> Self {todo!()} } } # impl DrawTarget for hal::Display{ type Color = embedded_graphics_core::pixelcolor::Rgb565; } struct DisplayWrapper<'a, T>{ display: &'a mut T, line_buffer: &'a mut [slint::platform::swrenderer::Rgb565Pixel], }; impl> slint::platform::swrenderer::LineBufferProvider for DisplayWrapper<'_, T> { type TargetPixel = slint::platform::swrenderer::Rgb565Pixel; fn process_line( &mut self, line: usize, range: core::ops::Range, 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 `1` as the const generic parameter which is our buffer count. // The buffer is not in our RAM, but actually within the display itself. // We just need to re-render what changed in the last frame. let window = slint::platform::swrenderer::MinimalSoftwareWindow::<1>::new(); const DISPLAY_WIDTH: usize = 320; let mut line_buffer = [slint::platform::swrenderer::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 }); }); // ... } ``` There might be faster way to do that than using the synchronous DrawTarget::fill_contiguous funciton to do that. For example, some device might be able to send the line to the display asynchronously using DMA. In that case, we'd have two line buffer. One working line, and one which is being send to the screen, in parallel. ## Our supported boards Our example use a support crate containing an implementation of the [`Platform`] trait for the device we tested. You can also make use of that crate, but you will need to use `git="..."` in your Cargo.toml