Introduction

Buoyant makes it easy to create flexible, dynamic, and (eventually) interactive UIs on embedded systems. It is designed to be used with the embedded-graphics crate, but can be adapted to other rendering targets.

The features and API language are heavily influenced by SwiftUI. If you're already familiar with SwiftUI, you should feel right at home with Buoyant. If you aren't, don't worry.

Why create this?

The vast majority of my frontend experience is with SwiftUI, and I just want to use it for embedded. Despite what Apple would like you to think, Swift isn't all that great for embedded, so here we are doing it in Rust.

The well known std Rust UI crates rely heavily on dynamic allocation, making them unsuitable for porting to embedded.

On the embedded side, at least as of the time of writing, there weren't any other solutions I found very satisfying. I'm not really interested in buying into the Slint ecosystem, and aside from that, you'd essentially be stuck manually placing elements with embedded-graphics. Not fun at all.

This is my attempt to fill that need, and at least so far, it's been far more successful than I imagined. While Buoyant is still very young and I still feel new to Rust, Buoyant is already capable of building fairly complex UIs in SwiftUI's declarative style. Animations are not only possible, but quite easy to add.

Quickstart

Embedded graphics simulator

To run examples, you'll need to follow the instructions in the embedded-graphics-simulator README to install sdl2.

Add dependencies

# Cargo.toml

[dependencies]
buoyant = "0.4"
embedded-graphics = "0.8"
embedded-graphics-simulator = "0.7.0"

Hello World

Running this example will result in the words "Hello" (green) and "World" (yellow) separated by as much space as possible, with 20 pixels of padding around the edges.

hello-world

Here is the full example, which will be picked apart in the following sections:

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;

use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout,
    render::{EmbeddedGraphicsRender as _, EmbeddedGraphicsView, Renderable as _},
    view::{padding::Edges, HStack, LayoutExtensions as _, RenderExtensions as _, Spacer, Text},
};
use embedded_graphics::{mono_font::ascii::FONT_10X20, pixelcolor::Rgb888, prelude::*};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::BLACK;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Hello World", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = hello_view();
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

fn hello_view() -> impl EmbeddedGraphicsView<Rgb888> {
    HStack::new((
        Text::new("Hello", &FONT_10X20).foreground_color(Rgb888::GREEN),
        Spacer::default(),
        Text::new("World", &FONT_10X20).foreground_color(Rgb888::YELLOW),
    ))
    .padding(Edges::All, 20)
}

Simulator Boilerplate

This is more or less the bare minimum to get a window up and running with the simulator.

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;

use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout,
    render::{EmbeddedGraphicsRender as _, EmbeddedGraphicsView, Renderable as _},
    view::{padding::Edges, HStack, LayoutExtensions as _, RenderExtensions as _, Spacer, Text},
};
use embedded_graphics::{mono_font::ascii::FONT_10X20, pixelcolor::Rgb888, prelude::*};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::BLACK;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Hello World", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    // Render to display...

    window.show_static(&display);
}

A window and a display framebuffer are created. display importantly conforms to embedded_graphics::DrawTarget<Color = Rgb888> and is what you'll render content into.

The framebuffer is cleared to the background color, content is rendered, and finally the framebuffer is displayed.

Environment

#![allow(unused)]
fn main() {
extern crate buoyant;
use buoyant::environment::DefaultEnvironment;

let environment = DefaultEnvironment::default();
let origin = buoyant::primitives::Point::zero();
}

For static views with no animation, the environment is mostly irrelevant and the default environment will suffice.

If this view involved animation, the environment would be used to inject the time (as a duration), which you'd set every time you produce a new view.

View Body

#![allow(unused)]
fn main() {
extern crate buoyant;
extern crate embedded_graphics;

use buoyant::view::{padding::Edges, HStack, LayoutExtensions as _, RenderExtensions as _, Spacer, Text};
use buoyant::render::EmbeddedGraphicsView;
use embedded_graphics::{mono_font::ascii::FONT_10X20, pixelcolor::Rgb888, prelude::*};

fn hello_view() -> impl EmbeddedGraphicsView<Rgb888> {
    HStack::new((
        Text::new("Hello", &FONT_10X20).foreground_color(Rgb888::GREEN),
        Spacer::default(),
        Text::new("World", &FONT_10X20).foreground_color(Rgb888::YELLOW),
    ))
    .padding(Edges::All, 20)
}
}

The view body returned from this function simply encodes the structure and relationships between elements, along with holding references to resources like text and fonts. Note it has no notion of size or position.

This is an example of a component view. Unlike SwiftUI where views are types, Buoyant components are functions (sometimes on types). You can take this view and compose it with other views the same way built-in components like Text are used.

Because embedded-graphics displays come in a wide variety of color spaces, component views must also specify a color space. Often it's useful to alias this to make migration to another screen easy, with e.g. type color_space = Rgb888.

Layout

let layout = view.layout(&display.size().into(), &environment);

The layout call resolves the sizes of all the views. It is a bug to try to reuse the layout after mutating the view, and Buoyant may panic if you do so.

Render Tree

let render_tree = view.render_tree(&layout, origin, &environment);

The render tree is a minimal snapshot of the view. It holds a copy of the resolved positions, sizes, colors, etc. of all the elements that are actually rendered to the screen. Relational elements like Padding, Frames, alignment, and so on have been stripped.

For rendering a static view, this feels like (and is) a lot of boilerplate from Buoyant. However, as you'll see later, having multiple snapshots allows incredibly powerful animation with next to no effort.

Rendering

render_tree.render(&mut display, &DEFAULT_COLOR, origin);

Here, the snapshot is finally rendered to the display buffer. A default color, similar to SwiftUI's foreground color, is passed in. This is used for elements that don't have a color set.

Building Views

This section is an introduction to building views with Buoyant. It covers the process of using Buoyant, and is not intended to be an exhaustive reference of available features. For that, refer to the Buoyant documentation on docs.rs.

Prerequisites

For all the examples in this section, it is assumed that you have installed the embedded-graphics-simulator requirements and have added the following dependencies to your Cargo.toml:

[dependencies]
buoyant = "0.4"
embedded-graphics = "0.8"
embedded-graphics-simulator = "0.7.0"

The boilerplate from the quickstart is used to drive all the examples in this section. I've hidden it to keep the examples concise, but know you can still see it by clicking the eye icon in case you want to run the example locally.

Stacks

Adjacent layout

HStack and VStack are the primary tools you'll use to arrange views side-by-side in Buoyant. Both stacks can contain a heterogeneous set of views and can be nested inside other stacks.

HStack

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;
use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout as _,
    render::{EmbeddedGraphicsRender as _, Renderable as _},
};
use embedded_graphics::{pixelcolor::Rgb888, prelude::*};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Example", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = view();
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

use buoyant::view::shape::{Circle, Rectangle};
use buoyant::view::HStack;
use buoyant::view::RenderExtensions as _;
use buoyant::render::EmbeddedGraphicsView;

fn view() -> impl EmbeddedGraphicsView<Rgb888> {
    HStack::new((
        Circle.foreground_color(Rgb888::CSS_CORAL),
        Rectangle
            .corner_radius(25)
            .foreground_color(Rgb888::CSS_DARK_ORCHID),
    ))
}

In this example, you can see HStack fairly offers both views half the available width. Rectangle greedily takes all the offered space on both axes, while Circle only takes space greedily on the shorter axis to retain its square aspect ratio.

Arranging views on top of each other

ZStack can be used to overlay views on top of each other. Like HStack and VStack, it can contain a heterogeneous set of views.

ZStack

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;
use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout as _,
    render::{EmbeddedGraphicsRender as _, Renderable as _},
};
use embedded_graphics::{pixelcolor::Rgb888, prelude::*};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Example", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = view();
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

use buoyant::view::padding::Edges;
use buoyant::view::shape::{Circle, Rectangle};
use buoyant::view::{LayoutExtensions as _, RenderExtensions as _};
use buoyant::view::ZStack;
use buoyant::render::EmbeddedGraphicsView;

fn view() -> impl EmbeddedGraphicsView<Rgb888> {
    ZStack::new((
        Rectangle
            .corner_radius(50)
            .foreground_color(Rgb888::CSS_DARK_ORCHID),
        Circle.foreground_color(Rgb888::CSS_CORAL),
        Circle
            .foreground_color(Rgb888::CSS_GOLDENROD)
            .padding(Edges::All, 25),
    ))
}

The .padding() modifier is useful here to create space around the topmost yellow circle.

Combining Stacks

Stacks can be nested to create complex layouts.

Mixed Stacks

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;
use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout as _,
    render::{EmbeddedGraphicsRender as _, Renderable as _},
};
use embedded_graphics::{pixelcolor::Rgb888, prelude::*};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Example", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = view();
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

use buoyant::view::padding::Edges;
use buoyant::view::shape::{Circle, Rectangle};
use buoyant::view::{LayoutExtensions as _, RenderExtensions as _};
use buoyant::view::{HStack, VStack, ZStack};
use buoyant::render::EmbeddedGraphicsView;

fn view() -> impl EmbeddedGraphicsView<Rgb888> {
    HStack::new((
        VStack::new((
            Circle.foreground_color(Rgb888::CSS_GOLDENROD),
            Circle.foreground_color(Rgb888::CSS_GHOST_WHITE),
        )),
        ZStack::new((
            Rectangle
                .corner_radius(50)
                .foreground_color(Rgb888::CSS_DARK_ORCHID),
            Rectangle
                .corner_radius(25)
                .foreground_color(Rgb888::CSS_CORAL)
                .padding(Edges::All, 25),
        )),
    ))
}

Stack conformance to the necessary traits is macro-derived up to stacks of 10 views

Alignment

When arranging elements in stacks, placement ambiguity can arise when the child views differ in length along the axis opposite to the arrangement axis.

Vertical stacks can have horizontal alignment ambiguity, and horizontal stacks can have vertical alignment ambiguity.

In the previous example, the HStack height resolved to the full window height because Rectangle takes height greedily. But the Circle frame is shorter so Buoyant needs to make a decision about where to place it. This ambiguity was resolved using the default, Center, but you can change it by calling .with_alignment() on the stack.

If you wanted to align the Circle from the previous example to the top edge, you could set VerticalAlignment::Top.

HStack with top alignment

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;
use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout,
    render::{EmbeddedGraphicsRender as _, Renderable as _},
};
use embedded_graphics::{pixelcolor::Rgb888, prelude::*};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Example", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = view();
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

use buoyant::layout::VerticalAlignment;
use buoyant::view::shape::{Circle, Rectangle};
use buoyant::view::HStack;
use buoyant::view::RenderExtensions as _;
use buoyant::render::EmbeddedGraphicsView;

fn view() -> impl EmbeddedGraphicsView<Rgb888> {
    HStack::new((
        Circle.foreground_color(Rgb888::CSS_CORAL),
        Rectangle
            .corner_radius(25)
            .foreground_color(Rgb888::CSS_DARK_ORCHID),
    ))
    .with_alignment(VerticalAlignment::Top)
}

You'll see this ambiguity arise again with ZStack and the frame modifiers.

A note on how SwiftUI alignment differs

If you're coming from SwiftUI, your understanding of alignment is most likely a bit wrong. This is probably fine, and conveniently, if you're wrong about SwiftUI you probably already have the correct understanding of alignment in Buoyant!

I think it's worth briefly explaining how SwiftUI alignment actually works and what that means for special alignments you can't build (yet) in Buoyant. Feel free to skip this if you're totally new to both and feeling lost.

Using HStack as an example, when you specify .bottom vertical alignment in SwiftUI you are not telling the HStack to align all the children to the bottom edge of the stack. You are telling it to align the .bottom alignment guides of all the children. Each child view reports a set of named marks, called alignment guides, which the stack uses for alignment. While generally you can assume .top is placed at y = 0 and .bottom is placed at y = frame.height, views are free to place these guides wherever they want. The stack simply lines up the mark across all the children. Custom marks are even preserved when nesting views inside other modifiers.

This is especially useful when you have a horizontal stack containing text of multiple sizes. You can tell SwiftUI to align the text baselines, giving a much more visually appealing result.

Buoyant does not have this feature, but I recognize the utility it provides. For now, you can only align to the center and outer edges. Thinking about alignment in terms of "Top aligns views to the top" is correct for Buoyant.

Stack Spacing

Stack spacing is the first spacing you should reach for. It applies fixed spacing between child views.

By default, VStack and HStack place their child views with no spacing in between. You can configure the spacing between child views using .with_spacing.

VStack with spacing

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;
use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout,
    render::{EmbeddedGraphicsRender as _, Renderable as _},
};
use embedded_graphics::{pixelcolor::Rgb888, prelude::*};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Example", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = view();
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

use buoyant::layout::HorizontalAlignment;
use buoyant::view::shape::{Capsule, Circle, Rectangle};
use buoyant::view::RenderExtensions as _;
use buoyant::view::VStack;
use buoyant::render::EmbeddedGraphicsView;

fn view() -> impl EmbeddedGraphicsView<Rgb888> {
    VStack::new((
        Circle.foreground_color(Rgb888::CSS_CORAL),
        Rectangle
            .corner_radius(25)
            .foreground_color(Rgb888::CSS_DARK_ORCHID),
        Capsule.foreground_color(Rgb888::CSS_GOLDENROD),
    ))
    .with_alignment(HorizontalAlignment::Trailing)
    .with_spacing(10)
}

Creating Visual Hierarchy

Often, you'll want to create a hierarchy which visually separates primitive elements, components, and sections of components. You can achieve this by nesting stacks and using incrementally larger values for each class of separation.

Spacing Hierarchy

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;
use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout,
    render::{EmbeddedGraphicsRender as _, Renderable as _},
};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Example", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = view();
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

use buoyant::layout::{HorizontalAlignment, VerticalAlignment};
use buoyant::view::padding::Edges;
use buoyant::view::shape::Circle;
use buoyant::view::{HStack, Text, VStack};
use buoyant::view::{LayoutExtensions as _, RenderExtensions as _};
use buoyant::render::EmbeddedGraphicsView;
use embedded_graphics::{
    mono_font::ascii::{FONT_7X13, FONT_9X15, FONT_9X15_BOLD},
    pixelcolor::Rgb888,
    prelude::*,
};

mod spacing {
    pub const ELEMENT: u16 = 6;
    pub const COMPONENT: u16 = 12;
    pub const SECTION: u16 = 18;
}

fn view() -> impl EmbeddedGraphicsView<Rgb888> {
    VStack::new((
        VStack::new((
            Text::new("Parents", &FONT_9X15_BOLD),
            contact_row(Rgb888::CSS_CORAL, "Alice", "Mother"),
            contact_row(Rgb888::CSS_DARK_ORCHID, "Bob", "Father"),
        ))
        .with_alignment(HorizontalAlignment::Leading)
        .with_spacing(spacing::COMPONENT),
        VStack::new((
            Text::new("Siblings", &FONT_9X15_BOLD),
            contact_row(Rgb888::CSS_GOLDENROD, "Clyde", "Brother"),
            contact_row(Rgb888::CSS_SKY_BLUE, "Denise", "Sister"),
        ))
        .with_alignment(HorizontalAlignment::Leading)
        .with_spacing(spacing::COMPONENT),
    ))
    .with_alignment(HorizontalAlignment::Leading)
    .with_spacing(spacing::SECTION)
    .padding(Edges::Horizontal, spacing::COMPONENT)
    .padding(Edges::Vertical, spacing::SECTION)
}

fn contact_row<'a>(
    color: Rgb888,
    name: &'a str,
    relationship: &'a str,
) -> impl EmbeddedGraphicsView<Rgb888> + use<'a> {
    HStack::new((
        Circle.foreground_color(color).frame().with_width(40),
        VStack::new((
            Text::new(name, &FONT_9X15),
            Text::new(relationship, &FONT_7X13),
        ))
        .with_alignment(HorizontalAlignment::Leading)
        .with_spacing(spacing::ELEMENT)
        .foreground_color(Rgb888::WHITE),
    ))
    .with_alignment(VerticalAlignment::Top)
    .with_spacing(spacing::ELEMENT)
}

The frames of Buoyant built-in views are always tight to the rendered content1. You should strive to maintain this property in your custom component views. Without it, you'll find it difficult to maintain well-organized hierarchies.

Watch out for alignment

Make sure you pay close attention to the alignment you set on each stack. Forgetting the alignment on the components VStacks would give you this weird result. Note how the frame of the outer VStack only actually extends to the end of the longest text.

Oops

While it's fairly obvious here that something is off, it can be much more subtle when you have text that spans the entire window width. Depending on where the text wraps, you may only sometimes see the problem.

1

Text rendered with embedded-graphics monospace fonts break the "tight frames" rule. If you look closely, you'll see that names which render entirely above the baseline (no g, j, p, q, or y) appear to have weird extra spacing underneath that doesn't match the other nearby element spacing. This is what you're trying to avoid by ensuring your frames are tight to the rendered content.

Separating Views

Spacer is used as a sort of shim to create flexible spacing between views in a stack.

Here, Spacer is used to push the two Circles to either side.

Spacer

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;
use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout,
    render::{EmbeddedGraphicsRender as _, Renderable as _},
};
use embedded_graphics::{pixelcolor::Rgb888, prelude::*};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Example", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = view();
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

use buoyant::layout::HorizontalAlignment;
use buoyant::view::shape::{Capsule, Circle, Rectangle};
use buoyant::view::RenderExtensions as _;
use buoyant::view::{HStack, Spacer, VStack};
use buoyant::render::EmbeddedGraphicsView;

fn view() -> impl EmbeddedGraphicsView<Rgb888> {
    VStack::new((
        HStack::new((
            Circle.foreground_color(Rgb888::CSS_CORAL),
            Spacer::default(),
            Circle.foreground_color(Rgb888::CSS_CORAL),
        )),
        Rectangle
            .corner_radius(25)
            .foreground_color(Rgb888::CSS_DARK_ORCHID),
        Capsule.foreground_color(Rgb888::CSS_GOLDENROD),
    ))
    .with_alignment(HorizontalAlignment::Trailing) // no effect!
    .with_spacing(10)
}

Note that with this update, changes to the VStack alignment no longer have any effect! The Spacer forces the HStack to always take the full width offered by the VStack, meaning the VStack child views will always have the same width. There is therefore no ambiguity in the alignment of the VStack children.

Mixed Alignment

Consider creating the following view with these requirements:

  • The purple circle should be aligned to the left, while the orange and yellow circles should be aligned to the right.
  • The layout should span the entire width of the window.
  • The circles should all be roughly a third the height of the window, with a bit of spacing.

Split alignment

Using Spacer to achieve alignment

You're probably thinking, "this feels like the perfect time to use Spacer!"

I'll briefly indulge this misconception.

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;
use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout as _,
    render::{EmbeddedGraphicsRender as _, Renderable as _},
};

use embedded_graphics::{pixelcolor::Rgb888, prelude::*};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Example", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = view();
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

// No!
use buoyant::layout::HorizontalAlignment;
use buoyant::view::shape::Circle;
use buoyant::view::RenderExtensions as _;
use buoyant::view::{HStack, Spacer, VStack};
use buoyant::render::EmbeddedGraphicsView;

fn view() -> impl EmbeddedGraphicsView<Rgb888> {
    VStack::new((
        Circle.foreground_color(Rgb888::CSS_CORAL),
        HStack::new((
            Circle.foreground_color(Rgb888::CSS_DARK_ORCHID),
            Spacer::default(),
        )),
        Circle.foreground_color(Rgb888::CSS_GOLDENROD),
    ))
    .with_alignment(HorizontalAlignment::Trailing)
    .with_spacing(10)
}

The VStack is set to align the trailing edges of its children simply because it's more convenient given there are more child views that need to be aligned to the right.

On their own, the Circles would not result in the VStack spanning the entire width, so an extra HStack is added with a Spacer to make sure at least one child spans the full width. It even serves a dual purpose of aligning the purple Circle to the left side!

This does produce the desired result, but there's a better way to achieve it.

A note for SwiftUI developers

In SwiftUI, Spacers have a 10 point minimum length. While it's probably not an issue for our specific example, using the HStack+Spacer solution in SwiftUI without manually overriding the Spacer minimum length results in 10 points of space your content can never fill. Text wraps a bit early and you can't put your finger on why you feel a little sad.

This is such a common mistake in SwiftUI that Buoyant uses 0 as the default minimum Spacer length. Now you can make this mistake and only I'll be sad.

Flexible Frames

A better way to get the same result is to use the flex_frame() modifier, which is closely related to the fixed .frame() modifier used earlier to set the width of the Circle in the contact_row component.

Instead of setting fixed dimensions, flexible frames allow you to create a virtual frame with configurable minimum and maximum dimensions. Child views won't necessarily take all the space within this frame, so you can also specify horizontal and vertical alignment to place the child within the virtual frame.

Buoyant provides .with_infinite_max_width() and .with_infinite_max_height() for creating virtual frames that are as wide or tall as possible. You can use this in combination with a leading horizontal alignment of the circle inside the frame to achieve the same result as the previous code.

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;

use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout as _,
    render::{EmbeddedGraphicsRender, Renderable},
};
use embedded_graphics::{pixelcolor::Rgb888, prelude::*};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Example", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = view();
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

// Preferred
use buoyant::layout::HorizontalAlignment;
use buoyant::view::shape::Circle;
use buoyant::view::VStack;
use buoyant::view::{LayoutExtensions as _, RenderExtensions as _};
use buoyant::render::EmbeddedGraphicsView;

fn view() -> impl EmbeddedGraphicsView<Rgb888> {
    VStack::new((
        Circle.foreground_color(Rgb888::CSS_CORAL),
        Circle
            .foreground_color(Rgb888::CSS_DARK_ORCHID)
            .flex_frame()
            .with_infinite_max_width()
            .with_horizontal_alignment(HorizontalAlignment::Leading),
        Circle.foreground_color(Rgb888::CSS_GOLDENROD),
    ))
    .with_alignment(HorizontalAlignment::Trailing)
    .with_spacing(10)
}

This is both faster for Buoyant to lay out and easier to read.

Preferred implementations

This behavior is so common, shortcuts exist to specify an infinite width or height with the alignment in one call.

Maximally wide views

#![allow(unused)]
fn main() {
extern crate buoyant;
use buoyant::layout::HorizontalAlignment;
use buoyant::view::LayoutExtensions as _;
use buoyant::view::{shape::Circle, HStack, Spacer};
let content = Circle;
// Avoid:
HStack::new((
    Spacer::default(),
    content,
))
;

// Preferred:
content
    .flex_frame()
    .with_infinite_max_width()
    .with_horizontal_alignment(HorizontalAlignment::Trailing)
;

// Preferred, shortcut:
content
    .flex_infinite_width(HorizontalAlignment::Trailing)
;
}

Maximally tall views

#![allow(unused)]
fn main() {
extern crate buoyant;
use buoyant::layout::VerticalAlignment;
use buoyant::view::LayoutExtensions as _;
use buoyant::view::{shape::Circle, VStack, Spacer};
let content = Circle;
// Avoid:
VStack::new((
    content,
    Spacer::default(),
))
;

// Preferred:
content
    .flex_frame()
    .with_infinite_max_height()
    .with_vertical_alignment(VerticalAlignment::Top)
;

// Preferred, shortcut:
content
    .flex_infinite_height(VerticalAlignment::Top)
;
}

Collections

In the Stack Spacing section, VStack was used to create what looks suspiciously like a list of identical rows. For this purpose, ForEach is typically a better choice. Use ForEach when you want to display a collection of like views.

ForEach

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;
use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout,
    render::{EmbeddedGraphicsRender, EmbeddedGraphicsView, Renderable},
};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Example", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = view(&SWATCHES);
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

mod spacing {
    pub const ELEMENT: u16 = 6;
    pub const COMPONENT: u16 = 12;
}

struct Swatch {
    name: &'static str,
    color: Rgb888,
}

use buoyant::layout::HorizontalAlignment;
use buoyant::view::padding::Edges;
use buoyant::view::{shape::RoundedRectangle, ForEach, HStack, Text};
use buoyant::view::{LayoutExtensions as _, RenderExtensions as _};
use embedded_graphics::{mono_font::ascii::FONT_9X15, pixelcolor::Rgb888, prelude::*};

static SWATCHES: [Swatch; 4] = [
    Swatch {
        name: "Indigo",
        color: Rgb888::CSS_INDIGO,
    },
    Swatch {
        name: "Indian Red",
        color: Rgb888::CSS_INDIAN_RED,
    },
    Swatch {
        name: "Dark Orange",
        color: Rgb888::CSS_DARK_ORANGE,
    },
    Swatch {
        name: "Mint Cream",
        color: Rgb888::CSS_MINT_CREAM,
    },
];

fn view(swatches: &[Swatch]) -> impl EmbeddedGraphicsView<Rgb888> + use<'_> {
    ForEach::<10>::new(swatches, |swatch| {
        HStack::new((
            RoundedRectangle::new(8)
                .foreground_color(swatch.color)
                .frame_sized(40, 40),
            Text::new(swatch.name, &FONT_9X15).foreground_color(Rgb888::WHITE),
        ))
        .with_spacing(spacing::ELEMENT)
    })
    .with_alignment(HorizontalAlignment::Leading)
    .with_spacing(spacing::COMPONENT)
    .padding(Edges::All, spacing::COMPONENT)
}

ForEach requires you to provide a const size, and will lay out and render up to that many items. As with VStack, the alignment and spacing of its subviews can be controlled.

#![allow(unused)]
fn main() {
extern crate buoyant;
use buoyant::view::{shape::RoundedRectangle, ForEach};
let radii = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

ForEach::<10>::new(&radii, |radius| {
    RoundedRectangle::new(*radius)
});
}

The provided closure will be called for each item in the collection to produce a view.

While the rows in this example are simple and mostly identical, conditional views can be used to create the illusion of heterogeneity.

Performance Considerations and Future Work

Diff Animation

Buoyant does not currently support animating between orderings of a collection, and will simply snap to the new ordering. This does not appear to be a difficult problem to solve and the API of ForEach is expected to change in future versions to accommodate it. It is likely to be through the introduction of an additional trait bound on the slice values.

Offscreen Elements

Laying out large collections can be expensive, especially when the collection is only partially onscreen. Ideally, Buoyant would attempt to guess at the size of the offscreen elements and reserve the N slots for items that are actually visible. Expect to see this behavior eventually implemented, but there is a lot of core functionality that must be built first.

Conditional Views

If you try something like this:

#![allow(unused)]
fn main() {
extern crate buoyant;
extern crate embedded_graphics;
use buoyant::render::EmbeddedGraphicsView;
use buoyant::view::{Text, shape::Rectangle};
use embedded_graphics::{mono_font::ascii::FONT_9X15, pixelcolor::Rgb888, prelude::*};

fn view(is_redacted: bool) -> impl EmbeddedGraphicsView<Rgb888> {
    if is_redacted {
        Rectangle
    } else {
        Text::new("This is visible!", &FONT_9X15)
    }
}
}

You'll of course get an error telling you that the types returned from each branch don't match.

Buoyant provides two macros for creating content conditionally.

Conditional Views with if_view!

The if_view! macro allows you to write views as if you were writing a plain if statement.

Redacted If View

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;
use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout,
    render::{EmbeddedGraphicsRender, EmbeddedGraphicsView, Renderable},
};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Example", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = view();
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

use buoyant::if_view;
use buoyant::view::{padding::Edges, shape::RoundedRectangle, LayoutExtensions as _, Text, VStack};
use embedded_graphics::{mono_font::ascii::FONT_9X15, pixelcolor::Rgb888, prelude::*};

fn secret_message(message: &str, is_redacted: bool) -> impl EmbeddedGraphicsView<Rgb888> + use<'_> {
    if_view!((is_redacted) {
        RoundedRectangle::new(4)
            .frame()
            .with_width(9 * message.len() as u16) // yeah yeah ignoring UTF8
            .with_height(15)
    } else {
        Text::new(message, &FONT_9X15)
    })
}

fn view() -> impl EmbeddedGraphicsView<Rgb888> {
    VStack::new((
        secret_message("Top secret message", true),
        secret_message("Hi Mom!", false),
        secret_message("hunter12", true),
        secret_message("Cats are cool", false),
    ))
    .with_spacing(10)
    .with_alignment(buoyant::layout::HorizontalAlignment::Leading)
    .padding(Edges::All, 10)
}

Variable Binding with match_view!

The match_view! macro is a more powerful version of if_view! that allows you to bind variables in the match arms.

Match View

extern crate buoyant;
extern crate embedded_graphics;
extern crate embedded_graphics_simulator;
use buoyant::{
    environment::DefaultEnvironment,
    layout::Layout,
    render::{EmbeddedGraphicsRender, EmbeddedGraphicsView, Renderable},
    view::EmptyView,
};
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};

const BACKGROUND_COLOR: Rgb888 = Rgb888::CSS_DARK_SLATE_GRAY;
const DEFAULT_COLOR: Rgb888 = Rgb888::WHITE;

fn main() {
    let mut window = Window::new("Example", &OutputSettings::default());
    let mut display: SimulatorDisplay<Rgb888> = SimulatorDisplay::new(Size::new(480, 320));

    display.clear(BACKGROUND_COLOR).unwrap();

    let environment = DefaultEnvironment::default();
    let origin = buoyant::primitives::Point::zero();

    let view = view();
    let layout = view.layout(&display.size().into(), &environment);
    let render_tree = view.render_tree(&layout, origin, &environment);

    render_tree.render(&mut display, &DEFAULT_COLOR, origin);

    window.show_static(&display);
}

use buoyant::match_view;
use buoyant::view::shape::{Rectangle, RoundedRectangle};
use buoyant::view::{padding::Edges, LayoutExtensions as _, RenderExtensions as _, VStack};
use embedded_graphics::{pixelcolor::Rgb888, prelude::*};

#[derive(Debug, Clone, Copy)]
enum Shape {
    Rectangle,
    RoundedRect(u16),
    None,
}

fn shape(shape: Shape) -> impl EmbeddedGraphicsView<Rgb888> {
    match_view!(shape => {
        Shape::Rectangle => {
            Rectangle
        },
        Shape::RoundedRect(radius) => {
            RoundedRectangle::new(radius)
        },
        Shape::None => {
            EmptyView
        }
    })
}

fn view() -> impl EmbeddedGraphicsView<Rgb888> {
    VStack::new((
        shape(Shape::Rectangle)
            .foreground_color(Rgb888::CSS_PALE_GREEN),
        shape(Shape::RoundedRect(10))
            .foreground_color(Rgb888::CSS_MEDIUM_ORCHID),
        shape(Shape::None)
            .foreground_color(Rgb888::WHITE),
        shape(Shape::RoundedRect(30))
            .foreground_color(Rgb888::CSS_INDIAN_RED),
    ))
    .with_spacing(10)
    .padding(Edges::All, 10)
}

Maintaining Consistent Spacing with EmptyView

Notice how despite returning a view for the Shape::None variant above, the correct spacing remains between its neighbors. EmptyView is useful when you must return a view, but don't want anything to be rendered and don't want to disrupt stack spacing.

Not all modifiers will transfer this spacing behavior when applied to an EmptyView.

When an if_view! does not specify an else, EmptyView is implied for the else branch.

#![allow(unused)]
fn main() {
extern crate buoyant;
extern crate embedded_graphics;
use buoyant::render::EmbeddedGraphicsView;
use embedded_graphics::pixelcolor::Rgb888;
use embedded_graphics::mono_font::ascii::FONT_9X15;
use buoyant::view::{Text, shape::Rectangle};
use buoyant::if_view;

/// A rectangle if not hidden, otherwise implicit `EmptyView`
fn maybe_rectangle(hidden: bool) -> impl EmbeddedGraphicsView<Rgb888> {
    if_view!((!hidden) {
        Rectangle
    })
}
}