DEV Community

Michal Ciesielski
Michal Ciesielski

Posted on

Building Zerocalc, part V - Iced UI, subscriptions, and code release!

The UI

In the last part of the series, we will build a simple UI for Zerocalc. We will use the Iced GUI library as it has a very nice API and very poor documentation which is a great opportunity to dig into its internals...

The UI will consist of two panes, one containing an editor to enter expressions and the other one showing a result:

.________________.
|.______________.|
||   2+2  |  4  ||
||        |     ||
||        |     ||
|.______________.|
.________________.
Enter fullscreen mode Exit fullscreen mode

The Iced-based application consists of three main elements:

  • Application state
  • View that defines how to visualize the state
  • Messages that define state changes

The state of Zerocalc UI consists of expressions entered by the user (input text) and the result of evaluating those expressions (output text). This can be described as the following structure:

struct Editor {
    content: text_editor::Content,
    result: String,
}
Enter fullscreen mode Exit fullscreen mode

The UI components will be laid out in a single row. I am using TextEditor widget for editing (created using text_editor helper function) and Text widget to display results (created using text helper function) with a vertical rule to separate them visually.

impl Application for Editor {

    fn view(&self) -> Element<Self::Message> {
        let input = text_editor(&self.content)
            .height(Length::Fill)
            .padding(0)
            .on_action(Message::Edit);

        let output = text(self.result.clone()).size(16);

        row![
            container(input).padding(12).width(Length::FillPortion(3)),
            Rule::vertical(2),
            container(output).padding(12).width(Length::FillPortion(1))
        ]
        .into()
    }

Enter fullscreen mode Exit fullscreen mode

The state is manipulated in two situations:

  • When the user edits the text
  • When expressions are evaluated

This means we need two messages - one that tells the input text should be updated (Edit), and one that tells results should be updated (Evaluate):

enum Message {
    Edit(text_editor::Action),
    Evaluate,
}
Enter fullscreen mode Exit fullscreen mode

With those messages, we can now implement the Iced update function that will manipulate the state:

// impl Application for Editor continued

    fn update(&mut self, message: Message) -> Command<Message> {
        match message {
            Message::Edit(action) => self.content.perform(action),
            Message::Evaluate => self.update_result(),
        };
        Command::none()
    }
Enter fullscreen mode Exit fullscreen mode

The update_result function just calls the parsing and evaluation functions described in previous episodes of the series and stores the output in self.result field. Details can be found in the full source code I link to at the end of this article.

The key problem to solve is how to trigger the Evaluate message. We could simply do this after each keystroke, but that's lots of wasted evaluations. I'd like to do some debouncing and only re-evaluate periodically, let's say every 250ms.

This sounds similar to displaying the clock on the screen which also is updated periodically. Luckily the Iced library comes with the clock example that shows how to do exactly that. Following the example we define the subscription method that will produce Evaluate messages every 250ms:

    fn subscription(&self) -> Subscription<Message> {
        iced::time::every(Duration::from_millis(250))
            .map(|_| { Message::Evaluate })
    }
Enter fullscreen mode Exit fullscreen mode

The iced::time::every function requires one of the asynchronous runtimes to be installed as a dependency. Zerocalc UI will use smol as it is smaller, simpler, and easier to reason about than the most popular tokio runtime. We need to add smol feature to iced dependency in Cargo.toml:

[dependencies]
iced = {version="0.12.1", features=["debug", "smol"] }
Enter fullscreen mode Exit fullscreen mode

Now we can build, run, and - voila, it works!

Zerocalc UI

Iced Subscription

While I am happy it does work, the magic in the subscription method is bothering me. How does the Subscription in Iced work?

Let's look at how Subscription is defined. It's part of iced_futures crate:

pub struct Subscription<Message> {
    recipes: Vec<Box<dyn Recipe<Output = Message>>>,
}
Enter fullscreen mode Exit fullscreen mode

It is a list of objects that implement Recipe trait. Since the compiler does not know what those recipes will be, they are kept as pointers to trait implementations, which in most object-oriented languages would be a pointer to an interface but in rust terms, it's a Box<dyn Recipe>. The Recipe trait has two methods - hash that a runtime can use to identify the recipe and stream that creates a stream of Messages:

pub trait Recipe {
    type Output;
    fn hash(&self, state: &mut Hasher);
    fn stream(self: Box<Self>, input: EventStream) -> BoxStream<Self::Output>;
}
Enter fullscreen mode Exit fullscreen mode

The stream method receives EventStream as input which it can (but does not have to) use to produce messages. EventStream is a stream of UI events such as keystrokes, mouse movements, window events, etc.

The iced::time::every function creates an instance of Recipe called Every and wraps it in the Subscription:

    pub fn every(
        duration: std::time::Duration,
    ) -> Subscription<std::time::Instant> {
        Subscription::from_recipe(Every(duration))
    }
Enter fullscreen mode Exit fullscreen mode

The Every implementation is simple - it ignores the EventStream parameters and just creates smol's timer. Timer in turn implements standard rust's Stream

    struct Every(std::time::Duration);
    impl subscription::Recipe for Every {
        type Output = std::time::Instant;

        fn hash(//...

        fn stream(
            self: Box<Self>,
            _input: subscription::EventStream,
        ) -> futures::stream::BoxStream<'static, Self::Output> {
            use futures::stream::StreamExt;

            smol::Timer::interval(self.0).boxed()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Going back to our subscription method implementation:

    fn subscription(&self) -> Subscription<Message> {
        iced::time::every(Duration::from_millis(250))
            .map(|_| { Message::Evaluate })
    }
Enter fullscreen mode Exit fullscreen mode

The iced:time::every creates a Subscription that every 250ms will spill out the current timestamp (as the std::time::Instance value). Since we need to produce instances of Message::Evaluate instead, we call Subscription::map that changes Subscription<std::time::Instance> into Subscription<Message>. We can ignore the timestamp value as we just need a trigger that tells us it's time to calculate results. A sample clock implementation could use this value to display the current time on the screen.

Now we know what Subscription does and how it is created. Let's take a look at how it is consumed. The entry point is in the application's main loop, which is defined in winit create that Iced uses to work with application windows across different platforms. Each time current UI events are processed, winit generates AboutToWait event that tells the application it's time to look at "other stuff". This triggers update function which is defined as follows:

/// Updates an [`Application`] by feeding it the provided messages, spawning any
/// resulting [`Command`], and tracking its [`Subscription`].
pub fn update<A: Application, C, E: Executor>(

    //do some more work not related to this article...

    let subscription = application.subscription();
    runtime.track(subscription.into_recipes());
}
Enter fullscreen mode Exit fullscreen mode

Here the Iced framework calls our subscription method and extracts recipes from the Subscription we returned. The recipes are passed on to runtime. The runtime updates the list of alive subscriptions. It uses the subscription's hash method to identify subscriptions. This explains the following comment from the Iced documentation:

A Subscription will be kept alive as long as you keep returning it, and the messages produced will be handled by update.

If we want to stop a subscription, we simply stop returning it from the subscription method and the runtime will remove it.

What happens next is runtime calls recipes to create streams and waits for next items to be returned from those streams. The runtime holds a reference to the application's event loop and each time an item is returned from the stream it is passed on to the event loop. Thanks to this if we return Message::Evaluate from the stream it will be put into the event loop and eventually will be passed on to our application's update method. And that's how we know ~250ms have passed and it's time to recalculate results.

Source code

Full source code created while writing this series is available on GitHub under MIT license:
https://github.com/michal1024/zerocalc

Sources:

  1. https://iced.rs/
  2. https://github.com/iced-rs/iced/tree/0.12
  3. https://docs.rs/smol/latest/
  4. https://rust-lang.github.io/async-book/05_streams/01_chapter.html
  5. https://github.com/rust-windowing/winit

Top comments (0)