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 ||
|| | ||
|| | ||
|.______________.|
.________________.
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,
}
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()
}
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,
}
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()
}
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 })
}
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"] }
Now we can build, run, and - voila, it works!
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>>>,
}
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 Message
s:
pub trait Recipe {
type Output;
fn hash(&self, state: &mut Hasher);
fn stream(self: Box<Self>, input: EventStream) -> BoxStream<Self::Output>;
}
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))
}
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()
}
}
}
Going back to our subscription
method implementation:
fn subscription(&self) -> Subscription<Message> {
iced::time::every(Duration::from_millis(250))
.map(|_| { Message::Evaluate })
}
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());
}
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:
Top comments (0)