PineTime Smart Watch running a [druid] app with a Label Widget and a Button Widget
Porting [druid] Rust Widgets to PineTime Smart Watch
UPDATE: This code in this article has been archived in the pre-lvgl branch of pinetime-rust-mynewt. The pinetime-rust-mynewt firmware has been revamped to support Rust Watch Faces on LVGL. Check out the updates
A button that responds to our tapping and increments a counter… That’s what we shall accomplish today on the PineTime Smart Watch…
The Watch App we see in the video was created with the [druid] Crate in Rust.
In [druid], UI controls are known as Widgets. Our app contains two Widgets: A Label Widget that displays the counter, and a Button Widget that increments the counter.
Lemme explain the code in the [druid] app…
pub fn launch() { | |
// Build a new window | |
let main_window = WindowDesc::new(ui_builder); |
From https://github.com/lupyuen/stm32bluepill-mynewt-sensor/blob/pinetime/rust/app/src/hello.rs
When the app is launched, we
create a new window, passing it the ui_builder
function that
will return a list of Widgets and their layouts. (More about ui_builder
later.)
// Application state is initially 0 | |
let data = 0_u32; |
From https://github.com/lupyuen/stm32bluepill-mynewt-sensor/blob/pinetime/rust/app/src/hello.rs
Here we set the Application State
to 0
. What’s this Application State?
Remember the counter value that’s incremented every time we tap the button? This counter value is stored in the [druid] Application State. Widgets will interact with the Application State: Our Button Widget updates the state and our Label Widget displays the state.
That’s why [druid] is known as a Data-Oriented Rust UI… [druid] Widgets are bound to data values in the Application State.
// Launch the window with the initial application state | |
AppLauncher::with_window(main_window) | |
.use_simple_logger() | |
.launch(data) | |
.expect("launch failed"); | |
} |
From https://github.com/lupyuen/stm32bluepill-mynewt-sensor/blob/pinetime/rust/app/src/hello.rs
Recall that main_window
contains a list of Widgets and their layouts. We’ll pass
this to the AppLauncher to launch the app window. The app shall be launched with data
, the Application State, set to 0. If the launch fails, we’ll
stop with an error launch failed
.
Now let’s look at ui_builder
and how it creates the Widgets…
/// Build the UI for the window. The application state consists of 1 value: `count` of type `u32`. | |
fn ui_builder() -> impl Widget<u32> { // `u32` is the application state | |
// Create a line of text based on a counter value | |
let text = | |
LocalizedString::new("hello-counter") | |
.with_arg( | |
"count", | |
// Closure that will fetch the counter value... | |
| data: &u32, _env | // Closure will receive the application state and environment | |
(*data).into() // We return the counter value in the application state | |
); |
From https://github.com/lupyuen/stm32bluepill-mynewt-sensor/blob/pinetime/rust/app/src/hello.rs
First we create a text
string that contains the counter value. How shall we get the
value of the text
string? Through this Rust Closure…
// Closure that will fetch the counter value... | |
| data: &u32, _env | // Closure will receive the application state and environment | |
(*data).into() // We return the counter value in the application state |
From https://github.com/lupyuen/stm32bluepill-mynewt-sensor/blob/pinetime/rust/app/src/hello.rs
What’s a Rust Closure? Think of it as a Rust Function without a name. The Closure above is equivalent to this verbose Rust Function…
fn nameless_func(data: &u32, _env: …) -> … { | |
(*data).into() | |
} |
So it’s simpler to use the
Closure form. The Closure simply returns the value of the counter in data
(the Application State) as the value of the text
string. The into
part
converts the returned value from number to string.
// Create a label widget to display the text | |
let label = Label::new(text); |
From https://github.com/lupyuen/stm32bluepill-mynewt-sensor/blob/pinetime/rust/app/src/hello.rs
Now that we have the counter
value stored in our text
string, let’s display it with a Label
Widget.
// Create a button widget to increment the counter | |
let button = Button::new( | |
"increment", // Text to be shown | |
// Closure that will be called when button is tapped... | |
| _ctx, data, _env | // Closure will receive the context, application state and environment | |
*data += 1 // We increment the counter | |
); |
From https://github.com/lupyuen/stm32bluepill-mynewt-sensor/blob/pinetime/rust/app/src/hello.rs
Here we create a button labelled increment that calls this Rust Closure whenever the button is tapped…
// Closure that will be called when button is tapped... | |
| _ctx, data, _env | // Closure will receive the context, application state and environment | |
*data += 1 // We increment the counter |
From https://github.com/lupyuen/stm32bluepill-mynewt-sensor/blob/pinetime/rust/app/src/hello.rs
This Closure is equivalent to the Rust Function…
fn nameless_func(_ctx: …, data: &mut u32, _env: …) { | |
*data += 1 | |
} |
data
contains our Application State, which is our counter value. The
Closure simply increments our counter value by 1, every time the button is tapped.
Hey Presto! That’s the magic behind our Data-Oriented UI… The button increments the counter in our Application State, the label displays the value of the counter in our Application State!
[druid] completes the magic act by wiring up the Widgets to the Application State… When our Application State changes (upon tapping the button), [druid] automagically refreshes our Label Widget to show the new counter value.
Let’s tell [druid] how to layout our Label and Button Widgets for display…
// Create a column for the UI | |
let mut col = Column::new(); | |
// Add the label widget to the column, centered with padding | |
col.add_child( | |
Align::centered( | |
Padding::new(5.0, label) | |
), | |
1.0 | |
); |
From https://github.com/lupyuen/stm32bluepill-mynewt-sensor/blob/pinetime/rust/app/src/hello.rs
We’ll display the Label and
Button Widgets in a single column col
. The Label Widget goes on
top, centered horizontally, with a padding of 5 pixels.
// Add the button widget to the column, with padding | |
col.add_child( | |
Padding::new(5.0, button), | |
1.0 | |
); |
From https://github.com/lupyuen/stm32bluepill-mynewt-sensor/blob/pinetime/rust/app/src/hello.rs
The Button Widget appears next in
col
. Also with a padding of 5 pixels.
Note that the second argument to
add_child
is the same for both the label and the button: 1.0
. This means that the label and button shall occupy equal
vertical spacing, i.e. the label shall fill the top half of the screen, the button shall fill the
bottom half. This is similar to the flexbox
concept in CSS.
// Return the column containing the label and button widgets | |
col | |
} |
From https://github.com/lupyuen/stm32bluepill-mynewt-sensor/blob/pinetime/rust/app/src/hello.rs
And lastly, we return the col
to the caller. To recap: col
contains the Label and Button Widgets, the Closures for
displaying and incrementing the Application State counter, and the layout of the Widgets.
That’s the clever simplicity of [druid] apps. [druid] apps have a Declarative UI like SwiftUI and Flutter… Without the legacy baggage of iOS and Android. That’s why I think [druid] is perfect for building Watch Apps for PineTime!
Here’s the complete [druid] Watch App for PineTime…
#![no_std] // This program will run on embedded platforms | |
use druid::widget::{Align, Button, Column, Label, Padding}; | |
use druid::{AppLauncher, LocalizedString, Widget, WindowDesc}; | |
pub fn launch() { | |
// Build a new window | |
let main_window = WindowDesc::new(ui_builder); | |
// Application state is initially 0 | |
let data = 0_u32; | |
// Launch the window with the initial application state | |
AppLauncher::with_window(main_window) | |
.use_simple_logger() | |
.launch(data) | |
.expect("launch failed"); | |
} | |
/// Build the UI for the window. The application state consists of 1 value: `count` of type `u32`. | |
fn ui_builder() -> impl Widget<u32> { // `u32` is the application state | |
// Create a line of text based on a counter value | |
let text = | |
LocalizedString::new("hello-counter") | |
.with_arg( | |
"count", | |
// Closure that will fetch the counter value... | |
| data: &u32, _env | // Closure will receive the application state and environment | |
(*data).into() // We return the counter value in the application state | |
); | |
// Create a label widget to display the text | |
let label = Label::new(text); | |
// Create a button widget to increment the counter | |
let button = Button::new( | |
"increment", // Text to be shown | |
// Closure that will be called when button is tapped... | |
| _ctx, data, _env | // Closure will receive the context, application state and environment | |
*data += 1 // We increment the counter | |
); | |
// Create a column for the UI | |
let mut col = Column::new(); | |
// Add the label widget to the column, centered with padding | |
col.add_child( | |
Align::centered( | |
Padding::new(5.0, label) | |
), | |
1.0 | |
); | |
// Add the button widget to the column, with padding | |
col.add_child( | |
Padding::new(5.0, button), | |
1.0 | |
); | |
// Return the column containing the label and button widgets | |
col | |
} |
From https://github.com/lupyuen/stm32bluepill-mynewt-sensor/blob/pinetime/rust/app/src/hello.rs
Powering [druid] with Type Inference
The above Rust source code for the watch app looks so trivial (compared with SwiftUI and Flutter)… Surely something must be missing in that app?
Oh yes, plenty is missing from the app… But the Rust Compiler performs Type Inference and fills in all the missing bits for us! Here’s how it looks with the missing bits filled in…
pub fn launch() { | |
// Build a new window | |
let main_window = WindowDesc::<u32,Flex<u32>>::new(ui_builder); | |
// Application state is initially 0 | |
let data = 0_u32; | |
// Launch the window with the initial application state | |
AppLauncher::<u32,Flex<u32>>::with_window(main_window) | |
.use_simple_logger() | |
.launch(data) | |
.expect("launch failed"); | |
} | |
/// Build the UI for the window. The application state consists of 1 value: `count` of type `u32`. | |
fn ui_builder() -> Flex<u32> { // `u32` is the window state | |
// Create a line of text based on a counter value | |
let text = | |
LocalizedString::<u32>::new("hello-counter") | |
.with_arg( | |
"count", | |
// Closure that will fetch the counter value... | |
| data: &u32, _env | (*data).into() | |
); | |
// Create a label widget to display the text | |
let label = Label::<u32>::new(text); | |
// Create a button widget to increment the counter | |
let button = Button::<u32>::new( | |
"increment", | |
// Closure that will be called when button is tapped... | |
| _ctx, data, _env | *data += 1 | |
); | |
// Create a column for the UI | |
let mut col = Column::new::<u32>(); | |
// Add the label widget to the column, centered with padding | |
col.add_child::<Align::<u32>>( | |
Align::<u32>::centered( | |
Padding::<u32>::new(5.0, label) | |
), | |
1.0 | |
); | |
// Add the button widget to the column, with padding | |
col.add_child::<Padding::<u32>>( | |
Padding::<u32>::new(5.0, button), | |
1.0 | |
); | |
// Return the column containing the label and button widgets | |
col | |
} |
Note that the Widgets have been
expanded as Label<u32>
and Button<u32>
. That’s because Label
and Button
are actually
Generic Types in Rust.
Quick recap of Generic Types:
Vec
is a Generic Type that represents a vector (array) of
values. When we write Vec<i32>
, it refers to a vector of
integer values (i32
).
Thus Label<u32>
and Button<u32>
are Generic Widgets that are backed by u32
, our Application State that contains the counter. Reminds us that [druid] is really a Data-Oriented UI!
Some types (like WindowDesc
) are Generic Types with two parameters. So this line of
code in our app…
let main_window = WindowDesc::new(ui_builder);
Actually expands to this complicated mess…
let main_window = WindowDesc::<u32,Flex<u32>>::new(ui_builder);
What a bloody clever compiler! The Rust Compiler makes it easy for us to write Watch Apps. Under the hood, things are a lot more complicated with the Generic Types. Read on to learn how [druid] Widgets are implemented on PineTime…
Downsizing [druid] from Desktop to Embedded
[druid] is a GUI toolkit that produces desktop apps for Windows, macOS and Linux. It doesn’t support Embedded Platforms like PineTime. So I had to change some features to make [druid] work on PineTime. (Only the [druid] implementation was changed, not the [druid] APIs… The same [druid] desktop application code can be compiled for PineTime without any code changes! Compare the above [druid] code for PineTime with the original version)
On Embedded Rust we declare our
programs as #![no_std]
.
This means that our embedded programs will call the Rust Core
Library, instead of the usual Rust
Standard Library which has many more features. Any code in [druid] that uses the
following features from the Rust Standard Library will NOT compile on PineTime…
Vec
vectors, because it uses Heap Memory to support resizable vectors. And Heap Memory is not available on embedded platforms (by default).String
(though&str
is supported), because it also uses Heap Memory.format!
, because internally it uses aString
to write formatted strings.Box
,RefCell
,Arc
(Atomically Reference Counted) andCow
(Clone On Write), because they use Heap Memory as well.
Why isn’t Heap Memory available on Embedded Rust? Embedded platforms like PineTime often have little RAM. (Only 64 KB RAM for PineTime’s Nordic nRF52832 microcontroller) When RAM is limited, we prefer to budget our memory requirements in advance… Just to be sure that our Smart Watch doesn’t allocate too many Widgets at runtime and crash.
Hence all space for Widgets should be preallocated in Static Memory before the Watch App starts. (There is a way to implement Heap Memory on embedded platforms, check this for details)
So many features are missing from Embedded Rust… Is it still possible to run [druid] apps on PineTime? Yes, I used some tricks to make a quick (and somewhat dirty) port of [druid] to PineTime… Which you can also use for porting Rust desktop libraries to embedded platforms! Read on to learn more…
Porting Vectors, Strings and format! to Embedded
The Vec
and String
types are used in many Rust libraries. On embedded platforms we can’t allow vectors and strings to be
resized on demand… But we can create vectors and strings that have a maximum size.
Here’s the quickest (and
dirtiest) way to use the [heapless] crate to replace
standard types Vec
and String
by vectors and strings with fixed size
limits…
/// Max vector size: 2 | |
type MaxVectorLength = heapless::consts::U2; | |
/// Vec is now redefined as a fixed-size vector | |
type Vec<T> = heapless::Vec::<T, MaxVectorLength>; | |
/// Max length of strings: 20 characters | |
type MaxStringLength = heapless::consts::U20; | |
/// String is now redefined as a fixed-size string | |
type String = heapless::String::<MaxStringLength>; |
From https://github.com/lupyuen/druid-embedded/blob/master/druid/src/localization.rs#L87-L91
(Yep told you this would be dirty… Should never override standard Rust types!)
Most of the surrounding code that
calls Vec
and String
should compile with this simple modification. (We may need to replace &str
by String
) Watch out
for the extra copies of vectors and strings that our program will now be copying… Keep them small!
Once the code works on our
embedded gadget, Do The Right Thing and rename Vec
and String
to something
sensible, like FixedVec
and FixedString
.
There’s a similar trick for
porting format!
to embedded platforms. Let’s say we are converting a number v to a text string…
format!("{}", v)
This fails to compile because
format!
uses an internal String
to store the formatted result.
The [heapless] solution uses the write!
macro like this…
/// Max length of strings: 20 characters | |
type MaxStringLength = heapless::consts::U20; | |
/// Declare a fixed-size string | |
type FixedString = heapless::String::<MaxStringLength>; | |
/// Buffer for the converted output | |
let mut buffer = FixedString::new(); | |
// Convert v to a text string and store in the buffer | |
write!(&mut buffer, "{}", v) | |
.expect("format fail"); | |
buffer // Contains v converted to a string |
From https://github.com/lupyuen/druid-embedded/blob/master/druid/src/argvalue.rs#L63-L66
Boxing up Widgets and Windows
Without Heap Memory, [druid]
can’t use Box
to allocate Widgets on the heap…
/// A widget container with either horizontal or vertical layout. | |
impl<T: Data> Flex<T> { | |
/// Add a child widget. | |
pub fn add_child(&mut self, child: impl Widget<T> + 'static, flex: f64) { | |
let params = Params { flex }; | |
let child = ChildWidget { | |
// Fails to compile on embedded! "boxed" not available | |
widget: WidgetPod::new(child).boxed(), | |
params, | |
}; | |
self.children.push(child); | |
} | |
} |
Original [druid] code that allocates Widgets on the heap. From https://github.com/xi-editor/druid/blob/master/druid/src/widget/flex.rs#L103-L151
Why does [druid]
use Box
Widgets?
Because [druid] has Container Widgets (Flex
/ Row
/ Column
) that contain
references to Child Widgets, like we see above.
In our embedded version, [druid]
uses a custom WidgetBox
type that pretends to be a Widget
in a Box
…
/// A widget container with either horizontal or vertical layout. | |
impl<T: Data + 'static + Default> Flex<T> { //// | |
/// Add a child widget. Now accepts a `Widget` Type (e.g. `Button<u32>`) as a Generic Parameter. | |
/// Also `child` is now a concrete `Widget` Type instead of `impl Widget<T>` | |
pub fn add_child<W: Widget<T> + Clone>(&mut self, child: W, flex: f64) { | |
let params = Params { flex }; | |
let child = ChildWidget { | |
widget: WidgetPod::new( | |
// Store the widget statically. Return a `WidgetBox` that contains the widget index. | |
WidgetBox::<T>::new(child) | |
), | |
params, | |
}; | |
self.children.push(child) | |
.expect("add child fail"); | |
} | |
} |
Modified [druid] code that uses static WidgetBox. From https://github.com/lupyuen/druid-embedded/blob/master/druid/src/widget/flex.rs#L126-L151
Here’s a crude implementation of
WidgetBox
… Instead of the heap, WidgetBox
stores Widgets in an array in Static Memory. WidgetBox
then returns the array index (Widget ID) at which the
Widget is stored….
/// Max number of `Widgets` on embedded platforms | |
const MAX_WIDGETS: usize = 10; | |
/// Static list of `Widgets` just for embedded platforms. TODO: Clean up with `singleton`: https://docs.rs/cortex-m/0.4.2/cortex_m/macro.singleton.html | |
static mut WIDGET_STATE_U32: [ WidgetType<u32>; MAX_WIDGETS ] = [ WidgetType::None, ... ]; // TODO: Simplify with `proc-quote`: https://crates.io/crates/proc-quote | |
/// Enum to store each type of `Widget`. `D` is the data type of the Application State, e.g. `u32` | |
pub enum WidgetType<D: Data + 'static + Default> { | |
None, | |
Align(Align<D>), | |
Button(Button<D>), | |
Flex(Flex<D>), | |
Label(Label<D>), | |
Padding(Padding<D>), | |
} | |
/// Boxed version of a `Widget`, which contains only the ID, not the actual `Widget` | |
pub struct WidgetBox<D: Data + 'static>( | |
u32, // Widget ID, the index of `WIDGET_STATE_U32` at which the `Widget` is stored | |
PhantomData<D>, // Needed to do compile-time checking for `Data` | |
); | |
/// Generic implementation of `WidgetBox` | |
impl<D: Data + 'static + Default> WidgetBox<D> { | |
/// Create a new box for the `Widget` with type `W` (e.g. `Button<u32>`) | |
pub fn new<W: Widget<D> + Clone>(widget: W) -> Self { | |
let id = widget.clone().get_id(); | |
// Wrap the `Widget` in a `WidgetType` enum | |
let widget_type: WidgetType<D> = widget.to_type(); | |
// Return a `WidgetBox` that contains the ID | |
let widget_box: WidgetBox<D> = WidgetBox( | |
id, | |
PhantomData, | |
); | |
// Store the `WidgetType` in the static array | |
widget_box.clone().add_widget(widget_type); | |
widget_box | |
} | |
} |
From https://github.com/lupyuen/druid-embedded/blob/master/druid/src/widget/widgetbox.rs
WidgetBox
implements the Traits (features) of a Widget by forwarding
the function calls to the Widget stored in Static Memory…
/// Implementation of `Widget` trait for `WidgetBox`. We just forward to the inner `Widget`. | |
impl<D: Data + 'static + Default> Widget<D> for WidgetBox<D> { | |
/// Paint the `Widget` | |
fn paint( | |
&mut self, | |
paint_ctx: &mut PaintCtx, | |
base_state: &BaseState, | |
data: &D, | |
env: &Env | |
) { | |
match &mut self.get_widgets()[self.0 as usize] { | |
// TODO: Simplify with `ambassador` https://github.com/hobofan/ambassador | |
WidgetType::Align(w) => w.paint(paint_ctx, base_state, data, env), | |
WidgetType::Button(w) => w.paint(paint_ctx, base_state, data, env), | |
WidgetType::Flex(w) => w.paint(paint_ctx, base_state, data, env), | |
WidgetType::Label(w) => w.paint(paint_ctx, base_state, data, env), | |
WidgetType::Padding(w) => w.paint(paint_ctx, base_state, data, env), | |
WidgetType::None => panic!("missing widget") | |
}; | |
} | |
... |
From https://github.com/lupyuen/druid-embedded/blob/master/druid/src/widget/widgetbox.rs
Storing Widgets statically can be
tricky… Our Widgets are Generic Types that depend on the data type of the Application State. Earlier
we have seen that the Rust Compiler expands our Watch App with labels and buttons as Label<u32>
and Button<u32>
The solution is to use a Specialised Trait like this…
/// Specialised Trait for handling static `Widgets` on embedded platforms | |
pub trait GlobalWidgets<D: Data + 'static + Default> { | |
/// Fetch the static `Widgets` for the Data type | |
fn get_widgets(&self) -> &'static mut [ WidgetType<D> ]; | |
/// Add a `Widget` for the Data type | |
fn add_widget(&self, widget: WidgetType<D>); | |
} | |
/// Default Trait will not have static `Widgets` | |
impl<D: Data + 'static + Default> GlobalWidgets<D> for WidgetBox<D> { | |
default fn get_widgets(&self) -> &'static mut [ WidgetType<D> ] { panic!("no global widgets") } | |
default fn add_widget(&self, _widget: WidgetType<D>) { panic!("no global widgets") } | |
} | |
/// Specialised Trait will store `Widgets` statically on embedded platforms | |
impl GlobalWidgets<u32> for WidgetBox<u32> { | |
/// Fetch the static `Widgets` for the Data type | |
fn get_widgets(&self) -> &'static mut [ WidgetType<u32> ] { | |
unsafe { &mut WIDGET_STATE_U32 } | |
} | |
/// Add a `Widget` for the Data type | |
fn add_widget(&self, widget: WidgetType<u32>) { | |
assert!(self.0 < MAX_WIDGETS as u32, "too many widgets"); | |
unsafe { WIDGET_STATE_U32[self.0 as usize] = widget; } | |
} | |
} |
From https://github.com/lupyuen/druid-embedded/blob/master/druid/src/widget/widgetbox.rs
Note the pattern for coding a Specialised Trait…
- Declare the Trait
across all types:
trait GlobalWidgets<D> { fn f(arg: D); ...
- Implement the Default
Trait:
impl<D> GlobalWidgets<D> { default fn f(arg: D) { ...
- Implement the
Specialised Trait for each specific type like
u32
:impl GlobalWidgets<u32> { fn f(arg: u32) { ...
The same trick is used to port
[druid] Windows to PineTime. Whenever we need to Box
up a
[druid] Window
, we use a WindowBox
instead. Windows and Window Handlers are now stored in a static array just
like Widgets.
[druid] Dependencies
Connect [druid] to PineTime’s Display and Touch Controllers
Now that [druid] compiles successfully on PineTime, let’s connect our fork of [druid] to the PineTime display and touch hardware…
[druid] calls the [piet] crate to render graphics and text. In our first PineTime article, we have created a Rust display driver based on the [embedded-graphics] and [st7735-lcd] crates.
Hence the solution: Adapt [piet] to render graphics and text with [embedded-graphics]. Here’s the resulting fork…
Here’s a peek of [piet] adapted for [embedded-graphics]: Rendering a Bezier path with [embedded-graphics] primitives (so that we can have rounded buttons)…
/// Render 2D graphics with embedded-graphics | |
impl RenderContext for EmbedRenderContext { | |
/// Render a Bezier path with embedded-graphics | |
fn stroke(&mut self, shape: impl Shape, brush: &impl IntoBrush<Self>, width: f64) { | |
let brush = brush.make_brush(self, || shape.bounding_box()); | |
// Get stroke color | |
let stroke = self.convert_brush(&brush); | |
// Draw a line for each segment of the Bezier path | |
let mut first: Option<Point> = None; | |
let mut last = Point::ZERO; | |
for el in shape.to_bez_path(0.1) { // Previously 1e-3 | |
match el { | |
PathEl::MoveTo(p) => { | |
if (first.is_none()) { first = Some(p); } | |
last = p; | |
} | |
PathEl::LineTo(p) => { | |
// Draw line from last to p with styled stroke | |
let last_coord = Coord::new(last.x as i32, last.y as i32); | |
let p_coord = Coord::new(p.x as i32, p.y as i32); | |
let line = Line::<Rgb565> | |
::new(last_coord, p_coord) | |
.stroke(Some(stroke)) | |
.stroke_width(width as u8) | |
.translate(get_transform_stack()); | |
unsafe { display::DISPLAY.draw(line); } | |
if (first.is_none()) { first = Some(p); } | |
last = p; | |
} |
Rendering Widgets with [embedded-graphics]. From https://github.com/lupyuen/piet-embedded/blob/master/piet-embedded-graphics/src/context.rs#L67-L199
The display driver from our first
PineTime article display.rs
has been relocated into the [piet]
library.
💎Advanced Topic: The [embedded-graphics] version of [piet] make take a few seconds to blank the screen or fill a region with colour (e.g. for buttons). I may enhance the display driver [st7735-lcd] to use SPI with DMA for such operations.
What about touch? In our second PineTime article, we have created a Rust Touch Controller Driver. This Touch Controller Driver has been wired up to send touch events to [druid]. [druid] doesn’t support touch events yet, so we simulate the mouse down + mouse up events instead…
/// Handle a touch event at coordinates (x,y) by simulating a mouse press: mouse down then up | |
pub fn handle_touch(x: u16, y: u16) { | |
let mut ctx = DruidContext::new(); | |
// Get the handler for the window | |
let handler = unsafe { &mut ALL_HANDLERS_U32[1] }; // Assume first window has ID 1 | |
// Simulate a mouse down event | |
handler.mouse_down( | |
&MouseEvent { | |
pos: Point::new(x as f64, y as f64), | |
count: 1, | |
button: MouseButton::Left, | |
}, | |
&mut ctx, | |
); | |
// Simulate a mouse up event | |
handler.mouse_up( | |
&MouseEvent { | |
pos: Point::new(x as f64, y as f64), | |
count: 0, | |
button: MouseButton::Left, | |
}, | |
&mut ctx, | |
); | |
} | |
/// ALL_HANDLERS[i] is the Window Handler for the Window with window ID i. i=0 is not used. | |
/// TODO: Clean up with `singleton`: https://docs.rs/cortex-m/0.4.2/cortex_m/macro.singleton.html | |
/// TODO: Simplify with `proc-quote`: https://crates.io/crates/proc-quote | |
static mut ALL_HANDLERS_U32: [ DruidHandler<u32>; MAX_WINDOWS ] = [ DruidHandler::<u32> { window_id: WindowId(0), phantom: PhantomData }, ... ]; |
Handling Touchable Widgets. From https://github.com/lupyuen/druid-embedded/blob/master/druid/src/win_handler.rs#L62-L90
💎Advanced Topic: If you check the video demo, most taps of the button respond within 1 second, but some taps on the button don’t seem to increment the counter, and some taps appear to increment the counter twice. That’s because the Touch Controller Driver has not been implemented completely.
The touchscreen controller supports multitouch, so the controller tracks each finger and reports whether each finger is a press (down) or release (up) action. The current Touch Controller Driver interprets only the first finger detected, and always assume it’s a press (down) action not a release (up) action.
Other Features Downsized in [druid] and [kurbo]
The above fork of [druid] has some features commented out…
- Localization: Our [druid] app uses a Localized String
hello-counter
… This actually refers to a Resource File that specifies the displayed text asCurrent value is <count>
. This is probably overkill for a proof-of-concept, so it has been stubbed out and replaced by a simpler version that displays only the first argument. - Themes and Environments: [druid] supports UI themes and configurable environment settings. For embedded platforms they should probably be implemented as compile-time constants. So Themes and Environments have been stubbed out for now.
- Menus and Keyboards: Since they are not necessary for Watch Apps
[druid] and [piet] use the [kurbo] crate for computing
Bezier curves, which are necessary for drawing rounded buttons. [kurbo] has been downsized to work
with PineTime and #![no_std]
…
- Bezier paths are limited to 16 points
- Double-precision floating-point (
f64
) math functions seem to be missing from the Rust Core Library, so we are using the [libm] crate instead. We could switch to single-precision floating-point (f32) and use [micromath] instead, because it’s optimised for embedded platforms.
Here is my fork of [kurbo] for PineTime…
PineTime ROM Usage for [druid] and Friends
[druid] + [piet] + [kurbo] + [embedded-graphics] + [st7735-lcd] + Rust Core Library + Mynewt OS… Will this long and exciting Conga Line even run on PineTime’s Nordic nRF52832 microcontroller with 512 KB ROM and 64 KB RAM?
Yes it does! Total ROM used is only 237 KB, less than half of the available ROM space!
And this was built in Debug Mode, not optimised Release Mode! (Which is hard to debug and learn)
What about ROM space for firmware upgrades? Well we have an additional 4 MB SPI Flash available on PineTime!
Total RAM used is 46 KB, which includes 32 KB of stack space (Probably too much)
So a functional Rust Widget UI for Watch Apps looks mighty feasible on PineTime!
I think it also helps when I
replaced the Box
references in [druid] by WidgetBox
and WindowBox
… The
Rust Compiler does an incredible job of removing dead code when everything is statically defined.
We can analyse the ROM bloat by loading this Linker Output Map into a Memory Map Google Sheet that’s explained in this article…
Functions that occupy the most ROM space, sorted by ROM size (in bytes). From https://docs.google.com/spreadsheets/d/1Lb217jqZGM7NlnOVCxpaLAI0CQcOTPnbMpksDSPVyiw/edit#gid=381366828&fvid=1643056349
The Google Sheet is here. It shows the functions that occupy the most ROM space, sorted by ROM size (in bytes). Some observations…
- [libm], the floating-point math library, seems to be taking the most ROM space. We could replace this by [micromath], a smaller math library for embedded platforms
core::num::flt2dec::strategy
needs closer study. And whydragon
and his friendgrisu
are hiding in my watch!- [druid] seems to take a fair amount of ROM space. Which is expected since [druid] was designed for desktop use. The ROM usage will probably drop drastically once we enable size optimisation in the Rust Release Build.
What’s Next?
Here are the other articles in the PineTime series…
I hope the maintainers of [druid], [piet], [kurbo], [embedded-graphics], [st7735-lcd] will consider incorporating some of the tweaks I have covered in this article. Together we can do so much more when the Rust Ecosystem embraces Embedded Platforms!
And I hope you agree with me that this is clearly the better way to code Watch Apps. There’s more work to be done to flesh this out fully for PineTime… Lemme know if you’re keen to help!
The source code in this article
may be found in the pinetime
branch of this repository…
The macOS build files for [druid], [piet], [kurbo], Embedded Rust and Mynewt OS for PineTime are available here…
I’ll be packaging a proper release when PineTime is available to the general public. Meanwhile if you have trouble building the source files (as explained in the first article), please drop me a note! The PineTime build, flash and debug steps on Visual Studio Code look like this…
PineTime build, flash and debug steps on Visual Studio Code
UPDATE: PineTime is now available for purchase by general public! I’m creating a new repository here that will have a prebuilt firmware image for PineTime. I’ll also include instructions for flashing the firmware to your PineTime with a Raspberry Pi.