GPUI Wheel of the Year, pt 1

As the civil year draws to an end, and the days get shorter, it’s a good time to reflect on your life, and find a path out of the dark that is troubling you. And one thing that troubles me as a person with ADHD is time.

It’s hard to remember the date, let alone the 8 traditional feast days of druidism, so I’ve decided to create a little calendar app that shows the next feast day and how many days until then. And what a great excuse to experiment with gpui, the rust ui framework from the makers of Zed.

Setup

cargo new --bin woy
cargo add gpui --version '*'
cargo add serde -F derive
cargo add anyhow serde_json dotenvy
cargo add chrono -F serde

Let’s start with creating our binary crate and adding some dependencies: gpui for the ui, anyhow for easy error printing, and serde + serde_json for loading our configuration file, and might as well throw dotenvy in there so we can use environment variables.

If you are on a mac and you haven’t worked with Metal before, you will probably need to do this:

xcodebuild -downloadComponent MetalToolchain

Hello, Year

A good place to start is just getting some pixels on the screen.

use chrono::prelude::*;
use gpui::{
    App, Application, Bounds, KeyBinding, Window,
    WindowBounds, WindowOptions, actions, div,
    prelude::*, px, rgb, size,
};

First we import a bunch of stuff, and then we define our application state. To start off with, let’s just grab today. We’ll want to eventually update the date/time as the application runs.

struct WheelOfTheYear {
    // TODO: festival dates
    today: DateTime<Local>, // Ignore time, only use date
}

impl Default for WheelOfTheYear {
    fn default() -> Self {
        Self {
            today: Local::now(),
        }
    }
}

impl Render for WheelOfTheYear {
    fn render(&mut self, _indow: &mut gpui::Window,
              _cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .size(px(500.0))
            .text_xl()
            .text_color(rgb(0xffffff))
            .child(format!("{}", self.today))
    }
}

The render function above handles laying out the UI. In this case, we are being pretty basic, just displaying the time as a string, using the default format for the date and time.

And now we start up the applcations, a 500x500 pixel window with black background and white foreground, displaying the time at which the application started.


fn main() {
    Application::new().run(|cx: &mut App| {
        let bounds = Bounds::centered(None,
          size(px(500.), px(500.0)), cx);
        let options = WindowOptions {
            window_bounds: Some(WindowBounds::Windowed(bounds)),
            focus: true,
            show: true,
            ..Default::default()
        };
        cx.open_window(options, |_, cx| {
            cx.activate(true);
            cx.new(|_| WheelOfTheYear::default())
        })
        .unwrap();
    });
}

If you run this with cargo run and then close the window, you’ll notice that it doesn’t end the application, so let’s fix that.

Handling Input

actions!(gpui, [Quit]);

First, we need to define an action that we can react to the application, and now let’s create the handler for it.

        cx.open_window(options, |_, cx| {
            cx.activate(true);
            cx.on_action(|_: &Quit, cx| cx.quit());
            cx.new(|_| WheelOfTheYear::default())
        })

In our original open_window call, we add a call to on_action with our Quit action to quit the application when this action happens, but how do we trigger the action?


        cx.open_window(options, |_, cx| {
            cx.activate(true);
            cx.bind_keys([
                KeyBinding::new("cmd-q", Quit, None),
                KeyBinding::new("escape", Quit, None),
            ]);
            cx.on_action(|_: &Quit, cx| cx.quit());
            cx.new(|_| WheelOfTheYear::default())
        })

So now we bind both cmd-q and the escape key to the quit action, but closing the window still doesn’t end the program! We need one more thing:

            cx.on_window_closed(|cx| cx.quit()).detach();

In theory, we should cycle to make sure it’s the last window, but we aren’t going to spawn new windows in this app, so this is safe.

But what is the .detach() about? on_window_closed() returns a subscription that must be used. detach just means, drop the handler after everything that uses the event handler is dropped.

That’s first pixels.

Give it a try!

Stay tuned for the next installment where we show an updating time clock.