ecton.dev

Introducing Gooey: My take on a Rusty GUI framework

Today, I've released v0.1.0 of Gooey, the first alpha of my attempt at building an easy-to-use, cross-platform, "native" graphical user interface framework for Rust. Gooey uses a reactive data model and renders widgets using a wgpu-powered, 2D graphics library (Kludgine).

Hello World

What does v0.1.0 mean?

This is alpha software. There are countless widgets that Gooey does not yet have, and those that are present almost certainly are missing features that many developers will need.

Despite this, it is possible to build some neat applications with Gooey today, and I decided I was unlikely to change its API significantly at this point without first hearing feedback from more people trying to use Gooey.

Why another GUI framework?

In short, I haven't been happy enough with my experiences trying to use existing frameworks. That's understandable, since building a GUI framework is a gargantuan task. When you add to it the challenges of playing nicely with Rust's type system, it is very hard to make something that is usable at all.

My struggles when using other frameworks generally centered around either having features that I consider a high priority incomplete or it being difficult to interoperate with async background tasks. While basic examples aren't very difficult, architecting complex user interfaces that were interacting with a remote server was never as easy as I wanted it to be.

Despite being labeled v0.1.0, this is not the first version of Gooey. It's the fourth! I've been on a bit of a journey that started four years ago. Just over a year ago, I wrote about my Rusty vision. That post tells my story. The relevant part is that I had given up on my second attempt at Gooey sometime in 2022, and that post was my attempt to hopefully find other people who shared similar visions.

One day I was reading some comments ModProg wrote about his enjoyment using leptos, which uses a reactive data model. Over the time I've gotten to know him, I've grown to value his opinions on a wide variety of topics, so his endorsement caused me to ponder: what would a reactive model in Gooey look like?

After initial experiments showed promise, I pursued this concept over the next 4-5 months. I continued to try to make Gooey platform agnostic, trying to keep my grandiose vision alive. However I kept running into limitations around generic associated lifetimes -- including one where the compiler told me it would be allowed in the future.

At the beginning of October, I decided I had enough of all of these abstractions I was building and asked myself: how much easier would this be if I just simply restricted Gooey to only ever work with wgpu + winit?

I hope the remainder of this post will show that this was a good idea. While I was able to copy aspects of my previous implementation, this latest iteration is largely an entire rewrite focused on one simple goal: keep it simple. The vast majority of what you see demonstrated in this post was developed within the last three months!

Gooey's Reactive Data Model

In Gooey, state is communicated through Dynamic<T> and Value<T> types. In general, widgets expose values they change as Dynamic<T> types, and values that they observe as Value<T>. Let's take a look at the simplest interactive Gooey example, a button that increments its own label.

use gooey::value::Dynamic;
use gooey::widget::MakeWidget;
use gooey::Run;

fn main() -> gooey::Result {
    let count = Dynamic::new(0_isize);
    let count_label = count.map_each(ToString::to_string);

    count_label
        .into_button()
        .on_click(move |_| count.set(count.get() + 1))
        .run()
}

The first line in the body initializes a Dynamic<isize> with 0. The second line creates a Dynamic<String> that contains the result of calling ToString::to_string() on the value contained in count. Not just the current value, but each value.

While this is only a few lines of code, it demonstrates a lot of Gooey's power. Gooey's Button widget can contain any widget as its label, and Gooey implements MakeWidget for String/Dynamic<String> to display them as Label widgets. This means that count_label.into_button() is actually producing the equivalent of calling Button::new(Label::new(count_label)).

Button::on_click invokes the provided callback each time the button is activated. Inside of the closure, we increment the value stored inside of count.

Finally, all widgets implement the Run trait, which allows them to be executed as a standalone window. This is the entire example, and it produces this application:

The count_label is automatically updated each time count is changed thanks to Gooey's reactive system.

This example shows how easy it can be to use this data model. Now let's look at something more complicated.

A Login Form

A good login form has several behaviors many users expect:

  • The ability to tab between widgets.
  • The ability to use the enter key to submit the form.
  • The ability to use the escape key to cancel the form/dialog.
  • Validation to show the user errors.

In Gooey, tab focus is built in and automatically supported in every application. Let's take a look at how to build a login form in Gooey.

We'll skip how the username field is set up, as it is almost exactly the same as the password field. The only difference is that the username input updates a Dynamic<String>, while the password input updates a Dynamic<MaskedString>.

let password_field = "Password"
        .align_left()
        .and(
            password
                .clone()
                .into_input()
                .placeholder("Password")
                .validation(
                    validations.validate(&password, |u: &MaskedString| match u.len() {
                        0..=7 => Err("passwords must be at least 8 characters long"),
                        _ => Ok(()),
                    }),
                )
                .hint("* required, 8 characters min")
                .tooltip(&tooltips, "Passwords are always at least 8 bytes long."),
        )
        .into_rows()

I designed the Input widget to be generic over types that implement the InputStorage trait. MaskedString is a string type that prevents accidentally logging its data using Debug/Display, zeroizes its data upon being dropped, and implements InputStorage to inform the Input it should be masked by default.

The next interesting part is the .validation() call, which wraps the password input in a Validated widget. This widget updates its children's styles when validation has failed, and shows a hint or the error. The validation closure is called each time password is updated, and it must return a Result<T, impl Display>.

Let's skip ahead to the Log In button:

"Log In"
    .into_button()
    .on_click(validations.when_valid(move |()| {
        println!("Welcome, {}", username.get());
        exit(0);
    }))
    .into_default()

The login button's on_click callback is wrapped in Validations::when_valid. This function first checks that all validations have been performed and that they are all returning Ok(_). Once that has been verified, the callback is invoked.

into_default() sets the widget as the target of the "default" action. Currently, this just means hitting the keyboard's enter key. There is a corresponding into_escape() that can be used on the cancel button.

All of this combined allows this example to be written in Gooey in 88 lines and provide most behaviors a user expects in a login form.

Taking Reactivity Further: Animations

When trying to figure out how I might "animate things" in Gooey, I realized that, in theory, a reactive system makes it easy to create animations. At the core an animation is changing one or more values (Dynamic<T>'s) over time.

To demonstrate how easy it is to use the animation API I developed, here is the line of code in the Scroll widget's implementation that animates hiding the scrollbars:

self.scrollbar_opacity_animation.handle = self
    .scrollbar_opacity
    .transition_to(ZeroToOne::ZERO)
    .over(Duration::from_millis(300))
    .with_easing(context.get(&EasingOut))
    .spawn();

The Scroll widget has a private property, scrollbar_opacity, with the type Dynamic<ZeroToOne>. ZeroToOne is a floating point number that is guaranteed to be between 0.0 and 1.0, inclusive. Dynamic::transition_to is available for any Dynamic<T> where T implements LinearInterpolate (and thanks to the aforementioned ModProg, this is a derivable trait). This returns a DynamicTransition. One or more dynamic transitions can be performed over a duration using AnimationTarget::over. By default, a Linear easing function is applied. This is overridden using Animation::with_easing(). Finally, Animation::spawn() is used to begin the animation, returning an AnimationHandle that cancels the animation when dropped.

The net result is that scrollbar_opacity will begin transitioning between its current value and 0.0 over the next 300ms. Because the Scroll widget calls Dynamic::get_tracking_redraw, the Scroll widget is automatically redrawn when the opacity changes.

As I finished this feature and started using it to build animations into Gooey, I realized how easy and powerful a reactive data model can truly be.

Scaling 2D graphics without floating points

One challenge in rendering graphics is ensuring that everything is perfectly seamless when the graphics card renders your image. If you've ever seen lines show up between textures in games or occasional gaps between tiles in a 2d game, these are sometimes caused by floating point math errors. While there are strategies to minimize the effects of floating point math errors, I wanted to try something different. Could a 2D graphics library that supported DPI scaling use purely integer-based math? So far, my surprising answer is: yes.

WARNING: Math ahead. If math isn't your cup of tea, feel free to skip to the next section.

I designed a math library that supports Logical Pixels (Lp) and Physical Pixels (Px). The Px type corresponds to the actual pixels of the output device with 4 subdivisions. The Lp type is 1/96th of an inch with 1,905 subdivisions. Why 1,905? I reasoned that there are some common "magic numbers" in graphic design:

  • Typographic Points: 72 points per inch
  • Desktop DPI: 96 pixels per inch
  • Centimeters to inches: 2.54

The challenge as I saw it was to pick a number as an arbitrary scale for logical pixels that minimized conversion errors. It also needed to be large enough to minimize rounding errors during DPI resolution scaling.

When listing the prime factors of 72, 96, and 254, you get: 2, 3, and 127. I decided that many people enjoy numbers that end in 5s, so I added 5 to that list. Because all of the magic numbers are divisible by 2, I've removed 2 from the list. That leaves us with 3 * 5 * 127 => 1,905. This is the "arbitrary scale" that logical pixels are built upon.

Why do these numbers matter? When converting between units, I'm multiplying or dividing by these magic numbers. By scaling the number using common factors, it helps ensure that the number will divide more evenly. For example, see how each of these conversions from units designers will want to use are converted to Lp with pure integer math and perfect precision.

// 1 * 1905 * 960 / 254 => 7,200
let one_mm = Lp::mm(1);
// 1 * 1905 * 96 => 182,880
let one_inch = Lp::inches(1);
// 1 * 1905 * 96 / 72 => 2,540
let one_point = Lp::points(1);

As you can see, having those prime factors compose the arbitrary scale ensures these operations are lossless.

Is all of this worth it? Well, with Px being integer based, I've never had an intermittent pixel alignment issue due to rounding errors in this iteration of Gooey. Either everything lines up or it doesn't, and it's always consistent and predictable. To me, that's worth all of the effort I put into the library.

Theming made easy

I've always considered Material Design to be a beautiful and simple design. When ModProg showed me how their color system worked, I realized it was an implementation of the vague goals I wanted in Gooey's theming system.

As part of building the theme support, I built a theme editor/visualizer. My wife said it reminded her of going and picking out paint swatches when we painted our house. In a way, this is very similar. Designers pick colors for specific roles to create a theme and widget/application authors choose colors from that theme based on the role of what they're drawing.

Picking colors is only half of the styling battle. Gooey also has style component system that can be used to adjust things like interior padding, text size, corner radii, and so much more.

Why I'm excited for Gooey

I'm excited for Gooey for so many reasons that aren't even mentioned here. As I hope this post has highlighted, I've put a lot of thought into the design of each piece of Gooey to try to create a fun, intuitive, Rusty framework.

I'm a team of one, currently. I have some beloved friends that have helped off and on along the way, yet the majority of Gooey and BonsaiDb have been written by me. I would love to find other likeminded people who share a vision of building amazing Rusty applications and games. I'm not ready for a large number of additional contributors, but having a few more people to share ideas between and collaborate with would be incredible.

I never have wanted to directly monetize BonsaiDb or Gooey, hence all of my work being licensing with both the MIT and Apache License 2.0 licenses. I enjoy building tools and take joy in seeing other people succeed using what I've made. That being said, if you enjoy my open-source contributions to the Rust ecosystem, I have a GitHub Sponsors page.

If you'd like to chat about Gooey or pretty much anything Rust related, consider joining my discord server, messaging me on Mastodon, opening an issue/discussion, or emailing me. I've also been uploading little devlogs to my YouTube channel.

Thank you for reading, and I hope you enjoy trying out Gooey!