Iris a UI library for no_std embedded Rust. I currently have it running on the ESP32-S3 based Lilygo T-Deck, but it should run on anything that uses the embedded_graphics traits. It focuses on bandwidth limited devices, such as SPI displays.
- Incremental redrawing using layout and dirty rect tracking.
- Built in components for buttons, labels, text input, toggles, and panels.
- Theming with colors and fonts
- Scene graph to manage a tree of View structs
- Fast single pass layout algorithm
- event loop: To make it flexible, the lib does not impose its own event loop. Instead, the application should send events to the scene and then redraw in its own loop. See Event Loop below.
- animation: The library has no support for animation or transparency because those will perform horribly on bandwidth limited SPI displays.
Add the crate iris-ui to your Cargo.toml file then use iris_ui::* in your code. Check out
the example code.
Build the library with cargo build.
Run the simulator example with cargo run --example simulator --features std. Note that the simulator needs
SDL2. Install instructions.
Run the unit tests with cargo test --features std.
Regenerate the screenshots in docs/layout.md from their own code snippets with
./scripts/gen_doc_screenshots.sh (no SDL required — it renders headlessly).
Rather than using inheritance, which is a bad fit for Rust, every component / widget / control is an instance of the
View struct. Views are referenced by their name property so the names should be unique throughout your application.
All views several mandatory fields like name, visible, bounds, etc, as well as optional fields for state, input
handlers, layout, and drawing.
| Field | Value | Description |
|---|---|---|
name |
string | should be unique throughout your application. |
title |
string | Used by buttons as the display text |
bounds |
Bounds (position & size) | should only be modified inside of the layout function |
v_flex |
grow, shrink or fixed | indicates if the view wants itself grow, shrink, or have a fixed size in the vertical direction |
h_flex |
grow, shrink or fixed | indicates if the view wants itself grow, shrink, or have a fixed size in the horizontal direction |
v_align |
start, center, or end | indicates how the view wants to be aligned vertically |
h_align |
start, center, or end | indicates how the view wants to be aligned horizontally |
visible |
bool | |
state |
Option<Box<dyn Any>> | optional object for the state of the view |
input |
Option<InputFn> | optional input handler function |
layout |
Option<LayoutFn> | optional layout function |
draw |
Option<DrawFn> | optional drawing function |
Views are rendered using a Theme which can be customized for different colors and font sizes. Views carry their own
internal state using an optional state struct. Application state should remain outside the scene/view structure and be
handled by processing actions emitted from the scene when events happen.
Instead of implementing a trait you create components by allocating a View is with optional fields for functions to
handle input, state, layout, and drawing. This is the code that creates a button (as implemented in the library provided
make_button):
pub fn make_button(name: &ViewId, title: &str) -> View {
View {
name: name.clone(),
title: title.to_string(),
// the button will determine its own width
h_flex: Intrinsic,
// the button will determine its own height
v_flex: Intrinsic,
// on release, requested to be focused
input: Some(|e| {
if let EventType::PointerUp(_pt) = &e.event_type {
e.scene.set_focused(e.target);
return Some(Action::Generic);
}
None
}),
// size self based on the font and the title text
layout: Some(|e| {
if let Some(view) = e.scene.get_view_mut(&e.target) {
view.bounds.size = util::calc_size(e.theme.bold_font, &view.title);
}
}),
// delegate drawing to a draw_button function
draw: Some(draw_button),
..Default::default()
}
}
fn draw_button(e: &mut DrawEvent) {
e.ctx.fill_rect(&e.view.bounds, &e.theme.bg);
e.ctx.stroke_rect(&e.view.bounds, &e.theme.fg);
if let Some(focused) = e.focused {
if focused == &e.view.name {
e.ctx.stroke_rect(&e.view.bounds.contract(2), &e.theme.fg);
}
}
draw_centered_text(
e.ctx,
&e.view.title,
&e.view.bounds,
&e.theme.bold_font,
&e.theme.fg,
);
}Iris has built in views for buttons, labels, text input, lists, and more. To use them call the make_* function from
the list below, then customize it using with_* functions. ex: make a center aligned invisible button:
let button_id = ViewId::new("button1");
let mut button = make_button( & button_id, "My cool button")
.with_h_align(Align::Center)
.with_visible(false);| name | function | description |
|---|---|---|
| button | make_button | standard button |
| primary button | make_full_button | with a command and primary color |
| toggle button | make_toggle_button | button with a selected state |
| label | make_label | a plain label |
| header label | make_header_label | a bold label in the accent color |
| scrolling list | make_list_view | a scrolling list of items with one selected |
| panel | make_panel | a standard panel |
| tabbed panel | make_tabbed_panel | panel with several tabs |
| text input | make_text_input | single line text input |
| toggle group | make_toggle_group | group of exclusive toggle buttons |
All views are just instances of the View struct. To create a custom view build a View with custom state, input,
layout, and draw fields. This example creates a simple progress bar.
First create a struct to represent the internal state of the progress bar:
// struct for the state of the progress bar
struct ProgressState {
value: f32,
}Now make a function to return a view with custom attributes.
fn make_progress_bar(name: &ViewId) -> View {
View {
name: name.clone(),
// set the state
state: Some(Box::new(ProgressState {
value: 0.0,
})),
// no input
input: None,
// fixed size layout
layout: Some(|e| {
if let Some(view) = e.scene.get_view_mut(e.target) {
view.bounds.size = Size::new(100, 20);
}
}),
// draw progress bar
draw: Some(|e| {
e.ctx.fill_rect(&e.view.bounds, &e.theme.bg);
let full = e.view.bounds.size;
// get the state to calculate the fill width
if let Some(state) = e.view.get_state::<ProgressState>() {
let w = (full.w as f32 * state.value) as i32;
let bd2 = Bounds::new_from(e.view.bounds.position, Size::new(w, full.h));
e.ctx.fill_rect(&bd2, &e.theme.selected_bg);
}
e.ctx.stroke_rect(&e.view.bounds, &e.theme.fg);
}),
// use defaults for the rest of the attributes
..Default::default()
}
}Now call the function to build the view and add it to your scene.
fn make_progressbar() {
let progress_id = ViewId::new("progress_bar");
scene.add_view_to_root(make_progress_bar(&progress_id));
}When the state of progress needs to change, update the state inside of a get_view_state() call.
fn update_progressbar() {
// update the progress bar every 100 msec
if let Some(state) = scene.get_view_state::<ProgressState>(&progress_id) {
state.value += 0.01;
if state.value > 1.0 {
state.value = 0.0;
}
scene.mark_dirty_view(&progress_id);
sleep(Duration::from_millis(100));
}
}See the full example code in examples/custom_view.rs.
Theme is a struct passed to every View's draw function. It stores the standard colors and fonts for drawing.
However, these are just guidelines. A view can feel free to ignore them and draw whatever it wants. The theme fields
should be used for:
- standard: fill and text colors for buttons, text inputs, and most interactive components.
- panel: fill and text colors for panels and containers — may differ from
standarddepending on the theme. - selected: colors used to indicate a selected or focused state.
- accented: highlight color, used for primary buttons or decorations.
- font: the default font used for all text.
- bold_font: the bold variant of the current font. Used for button titles.
Each color group is a ViewStyle<C> { fill: C, text: C } where C is the color mode. See below for more information on Color Modes.
Theme, View, Scene, and every widget constructor are generic over a color type C — any
embedded_graphics::pixelcolor::PixelColor (Rgb565, Rgb888, Gray8, BinaryColor, etc.) can be used. Most
call sites never need to write <C> explicitly: it's inferred from whatever theme/display you construct the scene
with. BW_THEME is a ready-made Theme<Rgb565>; for other color types, build your own Theme<C> literal.
On the device side, EmbeddedDrawingContext<'a, T, C> wraps an embedded_graphics::DrawTarget T and converts your
theme's logical color C into the display's native color T::Color via the FromColor<C> trait (in iris_ui::device).
This is what lets one Scene<Rgb565> drive both a full-color display and a 1-bit e-paper display side by side — see
examples/simulator.rs, which runs the same Theme<Rgb565> against both a Rgb565 SimulatorDisplay and a
BinaryColor one.
Fonts are represented by the FontKind enum. There are two variants:
Bitmap fonts use the built-in MonoFont types from embedded-graphics. They work on any target (std or
no_std) and require no extra dependencies:
use embedded_graphics::mono_font::ascii::{FONT_7X13, FONT_7X13_BOLD};
use iris_ui::{FontKind, Theme, BW_THEME};
let theme = Theme {
font: FontKind::Bitmap(FONT_7X13),
bold_font: FontKind::Bitmap(FONT_7X13_BOLD),
..BW_THEME
};TrueType fonts use fontdue for scalable, higher-quality text. Enable the
optional ttf feature in your Cargo.toml:
iris-ui = { ..., features = ["ttf"] }Then load a font from bytes and pass a reference to a 'static slot (the example below uses std; see the no_std
section for an alternative):
use std::sync::OnceLock;
use iris_ui::{FontKind, Theme, BW_THEME};
static FONT: OnceLock<fontdue::Font> = OnceLock::new();
let font: & 'static fontdue::Font = FONT.get_or_init(| | {
fontdue::Font::from_bytes(
include_bytes ! ("my_font.ttf"),
fontdue::FontSettings::default(),
).expect("failed to parse font")
});
let theme = Theme {
font: FontKind::TrueType { font, size: 13.0 },
bold_font: FontKind::TrueType { font, size: 14.0 },
..BW_THEME
};On no_std + alloc targets, use MaybeUninit in place of OnceLock:
static mut FONT_STORAGE: core::mem::MaybeUninit<fontdue::Font> =
core::mem::MaybeUninit::uninit();
// called once during init:
let font_ref: & 'static fontdue::Font = unsafe {
FONT_STORAGE.write(
fontdue::Font::from_bytes(
include_bytes!("my_font.ttf"),
fontdue::FontSettings::default (),
).expect("failed to parse font")
);
FONT_STORAGE.assume_init_ref()
};
let theme = Theme {
font: FontKind::TrueType { font: font_ref, size: 12.0 },
bold_font: FontKind::TrueType { font: font_ref, size: 12.0 },
..BW_THEME
};The simulator example includes a TTF button in the font-size toolbar when built with
cargo run --example simulator --features std,ttf. It loads a font from a known system path at runtime (Geneva on
macOS, DejaVu Sans on Linux).
Iris does not provide its own event loop. Instead use whatever loop is provided by the environment you are using. You
will need to receive native input events (touch/mouse down and up, keyboard presses, etc.) and convert them into Iris
events via pointer_down_at / pointer_up_at (or the click_at convenience wrapper, which performs both at once). In
a typical embedded environment, driving a touch controller that only reports "is a finger down right now" requires
tracking the down/up edge yourself, since the toolkit has no built-in debouncing:
#[main]
fn main() -> ! {
// set up your board and display
let mut display: Display<_> = make_display();
// init your scene
let mut scene = make_your_scene();
// create a theme
let theme = Theme {
bg: Rgb565::WHITE,
fg: Rgb565::BLACK,
selected_bg: Rgb565::WHITE,
selected_fg: Rgb565::BLACK,
panel_bg: Rgb565::CSS_LIGHT_GRAY,
font: FONT_6X10,
bold_font: FONT_7X13_BOLD,
};
// make the drawing context from the display
let mut ctx = EmbeddedDrawingContext::new(&mut display);
// init the touch screen
let touch = Gt911Blocking::default();
touch.init(i2c_ref).unwrap();
// event & render loop
let mut last_touch_point: Option<GPoint> = None;
loop {
// handle touch inputs, tracking the down/up edge ourselves
if let Ok(touch_point) = touch.get_touch(i2c_ref) {
match touch_point {
Some(point) => {
// flip because the screen is mounted sideways on the t-deck
let pt = GPoint::new(320 - point.y as i32, 240 - point.x as i32);
if last_touch_point.is_none() {
pointer_down_at(&mut scene, &vec![], pt);
}
last_touch_point = Some(pt);
}
None => {
if let Some(pt) = last_touch_point.take() {
if let Some(result) = pointer_up_at(&mut scene, &vec![], pt) {
info!("view returned result {result:?}");
}
}
}
}
}
// set up the clip rect
let delay_start = Instant::now();
ctx.clip = scene.dirty_rect.clone();
// draw the scene
draw_scene(&mut scene, &mut ctx, &theme);
// wait for 100 msec
while delay_start.elapsed() < Duration::from_millis(100) {}
}
}- Remove generics for color and font. Just use embedded graphics directly.
- use simulator for interactive tests
- use MockDisplay for automated tests
- support layout using font size. needs padding in the widgets.
- add hbox and vbox layouts
- make children drawn and picked relative to the parent.
- general
- setup CI on github actions.
- more components
- add menu view
- add list view
- drawing
- redo fill_text api.
- just text. support bg color?
- proper alignment. provide center point and draw centered
- draw line
- remove clear
- consolidate Display impls
- redo fill_text api.
- layout & rendering
- calculating dirty rect needs to be converted back to global
- common view padding
- new layout algoritm
- form layout -> grid layout
- debug lines
- alignment within grid cells
- span grid cells
- pick final name
- input improvements
- cleanup event types and action command signatures.
- document how to make your own event & draw loop
- text input
- move cursor within text
- forward and backward delete
- focus management
- use scroll events to jump between focused elements and perform selection.
- spec out how focus management works.
- focus groups
- improved custom view support
- view can define the children it uses
- let tab panel define its own children using a toggle group
- let tab panel switch its own tabs instead of using external handle action
- view can define the children it uses
- theme accent colors?
- e-paper support
- multi-line wrapping text