guillotine 0.2.1

A no_std graphical user interface framework in Rust for embedded devices prioritizing resource efficiency and ergonomics.
Documentation

Guillotine

CI Crates.io Docs.rs Ask DeepWiki

A no-std graphical user interface framework for embedded devices prioritizing resource efficiency and ergonomics. The UI declaration API is heavily inspired by GPUI.

Works everywhere embedded-graphics works.

Demo

A demo Guillotine UI on a Waveshare ESP32-C6 1.47" LCD board from the shellyctl project:

Demo of a power consumption monitor UI

Quickstart

use embedded_graphics::{
    prelude::*, 
    mock_display::MockDisplay, 
    pixelcolor::Rgb565,
    mono_font::ascii::FONT_9X18_BOLD,
};

use guillotine::*; 

struct BasicView {
    greeting: &'static str,
}

impl Render for BasicView {
    // Render lets you declaratively build your UI tree.
    fn render(&self, cx: &Context<'_>) -> impl ElementBuilder {
        cx.column()
            .padding(10)
            .margin(10)
            .border(2)
            .border_color(Rgb565::BLUE)
            .child(cx.text(self.greeting).background(Rgb565::RED).margin(5))
            .child(cx.text("GUILLOTINE").margin(5).font(Font::mono(&FONT_9X18_BOLD)))
    }
}

fn main() {
    // Display should implement embedded_graphics DrawTarget
    let display = MockDisplay::<Rgb565>::new();

    let view = BasicView {
        greeting: "Hello world!"
    };

    // Initialize stack-based storage for the frame. Capacity: 32 elements
    // and 128 bytes of UTF-8 text.
    let storage = FrameStorage::<Rgb565, 32, 128>::default();
    // Create a new UI with a direct display target. Used here for brevity;
    // if you have memory available, use `BufferedTarget`.
    let mut ui = Ui::new(DirectTarget::new(display), storage);

    // Render the view
    ui.render(&view);
}

Frame Buffers

The framebuffer feature (on by default) provides a BufferedTarget type that works with caller-provided frame buffers. It is highly recommended to use this over DirectTarget, since it can dramatically increase frame rates and reduce flicker to near unnoticeable if you can cover the whole display.

use embedded_graphics::{pixelcolor::Rgb565, mock_display::MockDisplay};
use static_cell::ConstStaticCell;
use guillotine::{*, buffered::BufferedTarget};

/// The size of the frame buffer, should ideally cover your whole display
/// (width * height).
const FRAMEBUFFER_PIXELS: usize = 320 * 172;

/// Allocate the buffer in static memory with your
/// chosen color.
static FRAMEBUFFER: ConstStaticCell<[Rgb565; FRAMEBUFFER_PIXELS]> =
    ConstStaticCell::new([Rgb565::new(0, 0, 0); FRAMEBUFFER_PIXELS]);

fn main() {
    let mut display = MockDisplay::<Rgb565>::new();
    
    // Initialize stack-based storage for the frame. Capacity: 32 elements
    // and 128 bytes of UTF-8 text.
    let storage = FrameStorage::<Rgb565, 32, 128>::default();

    /// Initialize the buffered display target 
    let target = BufferedTarget::new(&mut display, FRAMEBUFFER.take());
    
    let mut ui = Ui::new(target, storage);

    // ... render something
}

Large buffers should generally use static storage (with static_cell). Small buffers can live on the stack if the stack size permits it.

Note that the memory used by an Rgb565 buffer is pixels x 2 bytes.

Picking Sizes

Ideally, your frame buffer covers the whole display. However, you can provide a buffer of any size, and render() will execute a greedy top-down traversal to find the first subtree that fits. Passing buffers that are smaller than the area of any element on the screen will fall back to direct drawing.

[!NOTE] If your frame buffer doesn't fit the root element, it is painted directly before its children are considered. An opaque root background may therefore appear as a visible clear before buffered children are presented, so you may still notice a flicker.

Memory Management

Guillotine does not require an allocator, and uses heapless to store fixed-capacity node and text arrays inline.

This library exposes FrameStorage as the frontend for all memory management. The FrameStorage signature looks like this:

pub struct FrameStorage<C: PixelColor, const N: usize = 64, const T: usize = 1024>

Since heapless stores data inline, capacity has to be specified upfront through const generics. FrameStorage contains 2 buffers:

  1. nodes with capacity N (number of nodes): stores the tree of UI elements. 64 by default.
  2. text with capacity T (bytes): stores the UTF-8 bytes for all text elements present in the UI. 1024 by default.

It's recommended to tune N and T to fit the specifics of your UI. FrameStorage exposes some methods to help you do that:

  • usage() returns the used length of both buffers.
  • capacity() returns the capacity of both buffers.

[!NOTE] These buffers are only populated after calls to render(), and will contain the element tree and text bytes for the currently rendered frame.

Core Concepts

Declarative Definition

  • Explain the idea of declaratively building your UI

Status: implemented ✅

Hybrid Immediate & Retained Mode

Status: unimplemented ❌

Similar tree-based layout to X

  • GPUI

Status: implemented ✅

State Management

Layout Engine

  • Conceptually similar to Flutter (i.e. constraints go down, sizes go up)
    • constraints flow downward, sizes flow upward, positions flow downward
  • Requirement: single pass.

Status: implemented ✅

API

use embedded_graphics::{prelude::*, mock_display::MockDisplay, pixelcolor::Rgb565};

use guillotine::*;

struct Home {
    show_button: bool,
    header: &'static str,
}

impl Render for Home {
    fn render(&self, cx: &Context<'_>) -> impl ElementBuilder {
        cx.row()
            .bg(Rgb565::RED)
            .child(cx.text(self.header))
            .when(self.show_button, |row| row.child(cx.text("Click me")))
            .children([cx.text("Copyright"), cx.text("ACME Corp")])
    }
}

struct Page {
    power: &'static str,
    current: &'static str,
    voltage: &'static str,
}

impl Render for Page {
    fn render(&self, cx: &Context<'_>) -> impl ElementBuilder {
        cx.column()
            .child(cx.text(self.power))
            .child(cx.text(self.current))
            .child(cx.text(self.voltage))
    }
}

fn main() {
    let display = MockDisplay::<Rgb565>::new();

    let storage = FrameStorage::<Rgb565>::default();
    let mut ui = Ui::new(DirectTarget::new(display), storage);

    let mut home = Home {
        show_button: false,
        header: "Some Title",
    };

    ui.render(&home).unwrap();

    home.show_button = true;

    ui.render(&home).unwrap();

    let page = Page {
        power: "Power: 50.0 W",
        voltage: "Voltage: 230.0 V",
        current: "Current: 0.2173 A",
    };

    ui.render(&page);
}

Element trees use the display's PixelColor type throughout. Rgb565 views keep the API shown above; other targets select their color once at the Render<Color> boundary, after which element constructors and style methods infer it. See the binary-color example.

Insets and the box model

Margin, padding, and border widths accept CSS-like physical-edge shorthands:

cx.column()
    .margin(10)                 // all edges
    .padding((4, 8))            // vertical, horizontal
    .border((1, 2, 3))          // top, horizontal, bottom
    .margin((4, 8, 12, 16));    // top, right, bottom, left

Use Insets::new(top, right, bottom, left) when a named value is clearer. Insets are non-negative pixel lengths. Guillotine doesn't currently support percentages, auto, logical edges, negative margins, margin collapsing, per-edge border colors, or border styles. Adjacent margins in rows and columns add together.

Style::size is the border-box size: padding and border are placed inside it, and margin is added outside it. The box grows to contain its padding and border when parent constraints allow.

Examples

To run the examples, you need to enable the simulator feature. This pulls in a bundled sdl2 for opening windows. You will need cmake to compile it.

cargo run --example power_monitor --features simulator

Why?

I was trying to build a clean-looking dashboard on a small LCD screen powered by an ESP32-C6, that's supposed to monitor and display the power consumption of my home lab (project here). I wanted to do this in Rust, with the esp-rs ecosystem. The ecosystem is quite mature, but I couldn't really find a UI framework that was:

  1. Performant
  2. Very low memory footprint (no Slint / LVGL)
  3. Beautiful

Additionally, I wanted to learn what it would take to build something like this.

Roadmap

v0.0.1

  • Try to mimic GPUI declaration style: https://github.com/zed-industries/zed/blob/main/crates/gpui/examples/hello_world.rs
  • Low-level Element / ParentElement trait for custom elements and widgets
  • Support generic PixelColor
  • Full immediate mode redrawing
  • Make repo ready for publishing:
    • README documentation (a la Dioxus)
    • Rustdoc documentation
    • Examples
      • Sizing (insets)
      • Fonts
      • Cool
      • ESP32
    • Fix exports
    • Dual Apache / MIT license
    • Fix sdl2 vendoring for embedded-graphics-simulator
  • TextStyle fonts
  • Support for non-interactive elements:
    • Row
    • (formatted) Text
    • Column

v0.1.0

  • No alloc

v0.2.0

  • framebuffer feature with frame buffer support

Backlog

  • Add memory usage for examples
    • cargo binutils for examples in CI (cargo size). This will detect regressions.
  • Documentation
    • Guide for finding the ideal FrameStorage capacity.
  • Benchmarks for:
    • Frame rendering
    • Frame drawing
  • Stats for frame buffers (to determine optimal sizing)
  • Container gaps (flex from CSS)
  • Incremental drawing behind an inremental feature
  • New elements
    • Dialogs / Modals (floating containers)
    • Charts
    • Spinner
  • Overflow behaviour:
    • Visible
    • Clip
  • profile feature with defmt logs
  • Custom elements
  • Alignment
  • Support for interaction behind an interaction feature
  • Support interactive elements:
    • Button
    • Slider

Prior Work & Inspiration