Embedded Rust GUI Progress report

As part of my ongoing research with embedded Rust on ESP32 devices, (starting with the T-Deck but eventually branching out to other devices), I started creating a simple reusable UI toolkit to automate the boring code that I end up writing over and over for every example. The code is on github but I haven't made a crate for it yet.

Goals

The goal for this embedded GUI lib is to be able to quickly create basic and fast UIs on any device that supports Rust’s embedded_graphics API. Since most of these devices put the display on the SPI bus, they tend to be fill-rate limited. This means it’s very important that the GUI architecture lend itself to avoiding overdraw. The lib does this by tracking dirty rectangles and using a clip rect to only draw what has actually changed on screen. It also means the screen is only redrawn when something actually changes.

View Graphs in Rust

GUIs are traditionally done using a tree structure, however this is something that is especially difficult to model in Rust due to the borrow checker. Most GUIs end up becoming a spider web of references, which Rust won’t let you do. To address this challenge I’ve come up with a few stratgies.

  • No subclassing. Inheritance is simply a bad fit for Rust. We can sort of do it using traits, but it’s messy and error prone. Instead we have a single View struct that defines all views. It contains the basic state that all views need like a name, bounds, if it’s visible, etc. To create a Button or Label, we instead of using subclassing we set optional fucntions on the struct, sort of like methods, which leads us to:
  • Function references for customization. If you want to create a button then start with a view and make a draw_button fuction and set view.draw = Some(draw_button) to use it. The same pattern is used for state, handling input, and doing view layout. If a view doesn’t need that functionality then it sets that attribute to None.
  • View state is moduled using a struct that is only available to that view. Input on the view is only used for modifying that view state. Anything which affects another part of the application happens externally. For example, a toggle button has an internal state struct with a boolean indicating if it is selected. On input the toggle button can modify that itnernal state. Something in the app which needs to change when the button is toggled would not customize the button. Instead it would respond to an action event triggered by the button.
  • Responding to input happens outside of the View. Manipulating application state should not happen in the views. The views only modify their own state using input. To modify other state the application should listen to raw events on the scene or handle actions generated by views which are then returned to the application. This separation means Views are entirely isolated. They care only about their internal state and are easy to test. We never add an event handler to a view. Instead we trigger handlers when views produce actions.
  • Event handling goes in the main loop. A typical app has a loop to process input events from hardware (keyboard keys, touch screen touches, etc) and then redraw the screen if anything in the app has changed. This where application logic triggered by events should happen, not in the views.
  • Because parent child relationships are hard to modle using trees in Rust, we don’t use a tree. Instead all views are owned by a Scene object which tracks the parent child relationships. It provides utility methods to add a child view to a parent, get a list of all of a parent’s children, and draw the views in the correct order. Views are always reference by Strings rather than pointers to the views directly, avoiding ownership issues. Under the hood Scene uses a hashmap of vectors to model the logical View tree.
  • Progress So Far

    The Scene and View work pretty well. There are built in components for button, label, text input, toggle button, toggle group, panel, and a popup menu. All components are generic. They draw using the DrawingContext and Theme abstractions which are generic over Color and Font. This means a View can be created that will work with any drawing surface and any theme with any color and font type. The views draw using semantic colors and fonts provided by the Theme at runtime. I'm not completely happy with the type signatures. There's so many generics that you have to copy everywhere, but it seems to work.

    To use this library with a real device you need to provide implementations of DrawingContext and build your own app loop which generates input events and redraws the scene. Look at the esp-test example to see how to do it.

    To create your own views just assemble one at runtime. Build a View struct with your own values for state, input, layout, and draw. To have your code be notified when a view receives input, make the input function return an Action enum with some indication of what it did. Ex: a list view could return Action::Command(item) indicating which item was selected. Here's an example of a toggle button:

    pub struct SelectedState {
        pub selected: bool,
    }
    pub fn make_toggle_button<C, F>(name: &str, title: &str) -> View<C, F> {
        View {
            name: name.into(),
            title: title.into(),
            bounds: Bounds::new(0, 0, 80, 30),
            visible: true,
            state: Some(Box::new(SelectedState::new())),
            draw: Some(draw_toggle_button),
            layout: None,
            input: Some(input_toggle_button),
        }
    }
    
    fn draw_toggle_button<C, F>(
        e: &mut DrawEvent<C, F>,
    ) {
        e.ctx.fill_rect(&e.view.bounds, button_fill);
        e.ctx.stroke_rect(&e.view.bounds, &e.theme.fg);
     // rest of drawing code
    }
    
    fn input_toggle_button<C, F>(event: &mut GuiEvent<C, F>) -> Option<Action> {
    	// get the view state
        if let Some(state) = event.scene.get_view_state::<SelectedState>(event.target) {
    		// change it
            state.selected = !state.selected;
        }
    	// mark it dirty
        event.scene.mark_dirty_view(event.target);
        Some(Action::Command("toggled"))
    }
    

    Next Steps

    I built all of this so that I could make a tiny web browser for the LilyGo T-Deck. I have this code running in a different repo which makes use of this GUI repo. The repaint rates are reasonable but not great. So next up:

  • This lib needs a name. I could use some help on this.
  • I’m not happy with all of the dynamic casting that view functions need to do to access their own state. It feels like there should be a way to do this with just generics.
  • Everything is absolutely positioned. I’ve added an initial layout phase, but I haven’t worked out the details yet. Layout is especially important when we have themes and font size changes.
  • More built in widgets and GUI themes.
  • Examples beyond the T-Deck. I just bought a T5 E-Paper S3 Pro, so e-paper support is coming.
  • So.. I’ve still got more work to do. If you have questions or want to contribute please file a ticket or open a discussion question.

    Talk to me about it on Twitter

    Posted September 16th, 2025

    Tagged: rust embedded embeddedrust