Published 10/23/2023
Writing a native GUI app in Rust with Iced
By Asher White
Recently I needed a metronome, and instead of using one of the dozens of online ones, I decided to ‘just write one myself’. How hard can it be? Of course, I’m primarily a web developer, but for a small, lightweight and very time-sensitive application it made more sense to write a native binary that interfaces directly with the system. Unfortunately, building a cross-platform but lightweight GUI is a daunting task, but there are several exciting solutions written in the low-level language Rust, including Iced. I had a few other choices though: the easiest option would of course be something using Electron, but that’s almost the same as writing a web app, it still has the web browser abstraction layer. I use a Mac, so writing it in Swift would make sense, but I ended up going with Rust. Why Rust? I use it frequently and I’m more familiar with it than Swift, plus it’s a low-level, system programming language—writing a lightweight GUI seemed like a good fit. Plus, with Rust, the resulting app would be completely cross-platform, whereas Swift UIs are strictly for Apple products.
Once I decided to go with Rust, I needed to find a good GUI framework. There’s a lot of choices, from bindings to behemoths like GTK to experimental, bleeding-edge crates like rui and Floem. Personally, I’ve had trouble in the past getting and packaging the right version of GTK to use with a Rust program, plus the API is more cumbersome than idiomatic Rust. I would’ve liked to use Floem (the GUI framework used by the Lapce editor), but it’s still new, and the documentation doesn’t seem to be very complete. However, in the future, Floem and rui might be good candidates. Of the more established crates, Iced and eframe stood out, and I went with Iced because of it’s clear documentation, ergonomic API and simple aesthetic.
Iced Architecture
From the Iced docs: ‘Inspired by The Elm Architecture, Iced expects you to split user interfaces into four different concepts:
- State — the state of your application
- Messages — user interactions or meaningful events that you care about
- View logic — a way to display your state as widgets that may produce messages on user interaction
- Update logic — a way to react to messages and update your state’
What does this mean? It means you need two functions for a reactive UI: an update
function that responds to messages and changes internal state, and a view
function that takes the internal state and renders a UI. Anything that needs to change state is triggered by a message and handled by the update
function—state is never changed outside of the update
function. Isolating the state mutation to one function helps organize the internal structure and avoid bugs.
How is the update
function called? It gets called with a Message
argument that can be anything—using an enum lets you use several different kinds of messages and values. Message
values can be returned from UI elements to react to user actions, or from a subscription
function that sends Message
s when futures like timers or external calls return. All those Message
s run through the update
function which updates the internal state, and then the view
function runs to render the changes.
What does it actually look like? Here’s an edited example from my metronome app. (The full source code is available on GitHub.)
#[derive(PartialEq, Debug, Clone)]
enum MetroState {
Stopped,
FirstBeat,
Beat(u32),
}
#[derive(Debug, Clone)]
enum Message {
Toggle,
Beat,
OffBeat,
BPMUpdate(u32),
BarUpdate(u32),
FirstBeats(bool),
OffBeats(bool),
SetVolume(f32),
}
impl Application for Metronome {
type Executor = executor::Default;
type Flags = MetronomeSettings;
type Message = Message;
type Theme = Theme;
fn new(flags: MetronomeSettings) -> (Metronome, Command<Self::Message>) {
(
Metronome {
state: MetroState::Stopped,
bar: flags.bar,
bpm: flags.bpm,
accentuate_first_beat: flags.accentuate_first_beat,
off_beats: flags.off_beats,
player_thread: tx,
volume: flags.volume,
vol_tx,
},
Command::none(),
)
}
fn subscription(&self) -> iced::Subscription<Self::Message> {
match self.state {
MetroState::Beat(_) | MetroState::FirstBeat => {
time::every(Duration::from_secs_f64(60.0 / (self.bpm as f64))).map(
|_| Message::Beat
)
}
MetroState::Stopped => iced::Subscription::none(),
}
}
fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
match message {
Message::Beat => {
match self.state {
MetroState::FirstBeat => {
self.player_thread.send(Beat::Beat).unwrap();
self.state = MetroState::Beat(1);
}
MetroState::Beat(beat) => {}
}
Command::none()
}
// React to the other messages
}
}
fn view(&self) -> Element<Self::Message> {
let mut beats = Vec::new();
let current_beat = match self.state {
MetroState::Beat(n) => Some(n),
MetroState::FirstBeat => Some(0),
_ => None,
};
for i in 0..self.bar {
beats.push(
circle(25.0, if Some(i) == current_beat {
color!(0x6080df)
} else {
color!(0xe0e0e0)
}).into()
);
}
container(
// UI code here
column![
text(format!("{} beats per bar", self.bar)),
slider(2..=16, self.bar, |v| Message::BarUpdate(v)),
row![
checkbox("First beat accent", self.accentuate_first_beat, |val| {
Message::FirstBeats(val)
}).width(Length::FillPortion(1)),
checkbox("Off-beats", self.off_beats, |val| Message::OffBeats(val)).width(
Length::FillPortion(1)
)
]
.align_items(iced_native::Alignment::Center)
.width(450),
"Volume:",
row![
slider(0.1..=5.0, self.volume, |val| Message::SetVolume(val)).step(0.01),
text(format!("{}%", (self.volume * 100.0).round()))
].spacing(5.0)
]
.align_items(iced_native::Alignment::Center)
.spacing(10.0)
.max_width(450)
)
.width(Length::Fill)
.height(Length::Fill)
.center_x()
.center_y()
.into()
}
}
So, Message
s are returned from the subscription
function and also from callbacks added to UI elements. State is changed by the update
function, and then the view is rendered based on the state by the view
function.
Implementing the UI
Iced’s UI is based around reusable widgets, with an extensive library of built-in ones like textboxes, inputs, buttons, columns, rows, etc. Unfortunately, extending or heavily modifying widgets is difficult and sometimes impossible, so I had to write my own widget just to render a circle, which involves a lot of boilerplate code.
Another issue was the layout control—Iced heavily depends on the column
and row
macros, which work a lot like Flexbox in CSS. What makes it hard is that there aren’t many layout controls (like margins, offsets, alignments, etc.) for each widget, so you end up with a lot of nested columns, rows and containers to get the layout you need.
On the bright side, the built-in library of inputs work great—for the metronome I only used sliders, buttons and checkboxes, but they all work smoothly. Because of Iced’s message-passing architecture, input elements never change their own state—instead, they send a message to the update
function that changes the state and then they get re-rendered with view
. This leads to a lot of different Message
enum branches, and a repetitive update
function, but it keeps all the state in one place which saves bugs down the line.
Coordinating the Iced GUI with Audio
Iced made it pretty easy to have a working, fast GUI, but a metronome is useless without audio. CPAL is the go-to Rust library for low-level audio output, but I used Rodio for a higher-level approach. I included the metronome-click WAV files right in the Rust file and used lazy_static!
to create buffered Rodio Sources
that could just be cloned every time a click needed to be played.
Iced’s message-based state model makes it easy to fit other libraries into the event loop—whenever a Message::Beat
event from the timer made it to the update
function, the update
function sent a message to the audio playback thread, which played the right click.
When I tried to play the audio right from the Iced event loop, there was silence—the OutputStream
value that represents the output device didn’t last long enough because it was dropped after the update
function returned. Moving audio playback to its own thread meant using the same OutputStream
for the whole program lifetime, which worked.
Conclusion
By the end of it, I had a lightweight, cross-platform, native GUI app with minimal dependencies and good performance. You can check out the full code on GitHub. I could use Fruitbasket to package everything up into a macOS app bundle, and the result was only 9.6 MB on Mac (17 MB on Linux). It worked properly on both Mac and Linux with a single common codebase (I didn’t test Windows, but it would probably still work).
Is Iced the ideal tool for GUI development? No, none of Rust’s GUI frameworks are the silver bullet for cross-platform GUI’s, but there’s room for progress with a solid foundation with crates like Iced, and some exciting new ones like Floem and rui. Ideally, you’d just use the native GUI framework for each of your target OSes, but Rust’s GUI frameworks are an functional and improving alternative for lightweight projects.