guillotine 0.0.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:

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<'a>(&'a self, _cx: &mut Context) -> impl IntoElement<Element = Element<'a>> {
        column()
            .padding(10)
            .margin(10)
            .border(2)
            .border_color(Rgb565::BLUE)
            .child(text(self.greeting).background(Rgb565::RED).margin(5))
            .child(text("GUILLOTINE").margin(5).font(Font::mono(&FONT_9X18_BOLD)))
    }
}

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

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

    let mut ui = Ui::new(display);

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

Core Concepts

Declarative Definition

  • Explain the idea of declaratively building your UI

TODO: Hybrid Immediate & Retained Mode

TODO: Similar tree-based layout to X

  • GPUI

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.

API

extern crate alloc;

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<'a>(&'a self, _cx: &mut Context) -> impl IntoElement<Element = Element<'a>> {
        row()
            .bg(Rgb565::RED)
            .child(text(self.header))
            .when(self.show_button, |row| row.child(text("Click me")))
            .children([text("Copyright"), text("ACME Corp")])
    }
}

struct Page {
    power: f32,
    current: f32,
    voltage: f32,
}

impl Render for Page {
    fn render<'a>(&'a self, _cx: &mut Context) -> impl IntoElement<Element = Element<'a>> {
        column()
            .child(text(alloc::format!("Power: {}", self.power)))
            .child(text(alloc::format!("Current: {}", self.current)))
            .child(text(alloc::format!("Voltage: {}", self.voltage)))
    }
}

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

    let mut ui = Ui::new(display);

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

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

    home.show_button = true;

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

    let mut page = Page { 
        power: 50.0,
        voltage: 230.0,
        current: 0.2173
    };

    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:

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

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
  • Benchmarks for Frame building
  • TextStyle fonts
  • Support for non-interactive elements:
    • Row
    • (formatted) Text
    • Column
    • Spinner
  • Container gaps

v0.1.0

  • Custom render modes: Incremental (only repaint changed regions, requires more memory), or Redraw (full redraw on every render, lowest memory footprint). Either as a feature or runtime flag.
  • Define inremental redrawing triggers / states:
enum DrawState {
    Clean,
    Paint,     // same geometry; repaint old/current bounds
    Layout,    // size or position changed
    Structure, // child added, removed, moved, or keyed differently
    Full,      // theme, rotation, display reset, etc.
}
  • New elements
    • Dialogs / Modals (floating containers)
    • Charts
  • Overflow behaviour:
    • Visible
    • Clip
  • profile feature with defmt logs
  • No alloc
  • Custom elements
  • Alignment
  • Support for interaction
  • Support interactive elements:
    • Button
    • Slider

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.

Prior Work & Inspiration