How I'm learning Rust with UI programming
Table of Contents
tags:
- projects/on-going
Rust has quickly become a major player in the low-level programming sector, adapting to use cases once thought impossible. Learning a new language effectively is a skill that’s as valued as gold.
UI in Rust
Rust’s UI libraries are still relatively immature, but this is actually an advantage. They don’t carry the baggage of GTK’s or Qt’s aged architecture, nor React’s complexity. They can be engineered from the ground up to be maintainable.
Iced-rs
Iced-rs is built on Elm’s architecture. It uses a declarative approach to UI programming. While the syntax may seem unfamiliar at first, Iced-rs code becomes the easiest to read out of all UI libraries once you understand the core concepts. For instance, have a look at the following three excerpts from React, Qt, and GTK.
React
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
export default Counter;
React’s disadvantages are two-fold:
- Firstly, mixing HTML and JavaScript code will always turn out messy. If you take a short glance at a large React project you’ll just see how messy the two languages get. The grand architecture is similar to Iced-rs, but JavaScript’s loose typing practices and the merging of the two languages ruin the clarity.
- Secondly, props and state management. Nested components will cause prop drilling, the practice of passing props through multiple components just to access them in the grand scheme. It adds a lot of transitive dependencies that are unnecessary.
Qt
#include <QApplication>
#include <QWidget>
#include <QPushButton>
#include <QLabel>
#include <QVBoxLayout>
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
QWidget window;
window.setWindowTitle("Counter");
QVBoxLayout *layout = new QVBoxLayout(&window);
QLabel *label = new QLabel("Count: 0");
QPushButton *button = new QPushButton("Increment");
layout->addWidget(label);
layout->addWidget(button);
int count = 0;
QObject::connect(button, &QPushButton::clicked, [&count, label]() {
count++;
label->setText(QString("Count: %1").arg(count));
});
window.show();
return app.exec();
}
Qt’s main fault is its object-oriented design. Having to look up every widget and every method is tiresome. This issue is aggravated by Qt’s lack of standardisation. Each widget is defined differently, like QVBoxLayout and QPushButton, both widgets; both have completely different constructors.
GTK
#include <gtk/gtk.h>
typedef struct {
GtkWidget *label;
int count;
} AppData;
static void on_button_clicked(GtkWidget *widget, gpointer data) {
AppData *app_data = (AppData *)data;
app_data->count++;
char text[32];
snprintf(text, sizeof(text), "Count: %d", app_data->count);
gtk_label_set_text(GTK_LABEL(app_data->label), text);
}
static void activate(GtkApplication *app, gpointer user_data) {
GtkWidget *window = gtk_application_window_new(app);
gtk_window_set_title(GTK_WINDOW(window), "Counter");
gtk_window_set_default_size(GTK_WINDOW(window), 300, 150);
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
gtk_widget_set_halign(box, GTK_ALIGN_CENTER);
gtk_widget_set_valign(box, GTK_ALIGN_CENTER);
gtk_window_set_child(GTK_WINDOW(window), box);
static AppData app_data = { NULL, 0 };
app_data.label = gtk_label_new("Count: 0");
GtkWidget *button = gtk_button_new_with_label("Increment");
gtk_box_append(GTK_BOX(box), app_data.label);
gtk_box_append(GTK_BOX(box), button);
g_signal_connect(button, "clicked", G_CALLBACK(on_button_clicked), &app_data);
gtk_widget_show(window);
}
int main(int argc, char **argv) {
GtkApplication *app = gtk_application_new("com.example.counter", G_APPLICATION_DEFAULT_FLAGS);
g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
int status = g_application_run(G_APPLICATION(app), argc, argv);
g_object_unref(app);
return status;
}
GTK does the job a bit better, but can you imagine just how many functions there are in a large-scale app? The code will reach thousands of lines that all need to be documented and understood by a programmer before they can make any meaningful changes.
The four core principles of Iced-rs
Iced-rs is built on four core principles:
- The Model — An immutable data structure holding your application’s state.
- Messages — Events (user clicks, file selections, timers) encoded as enum variants.
- The Update Function — Pure logic that takes the current model and a message, returning a new model and optional async tasks.
- The View Function — A pure function that renders the UI from the current model. Declarative, not imperative.
Here’s what a minimal Iced-rs app looks like:
#[derive(Default)]
struct Model {
count: i32,
}
#[derive(Clone)]
enum Message {
Increment,
Decrement,
}
fn update(model: &mut Model, message: Message) -> Task<Message> {
match message {
Message::Increment => {
model.count += 1;
Task::none()
}
Message::Decrement => {
model.count -= 1;
Task::none()
}
}
}
fn view(model: &Model) -> Element<Message> {
column![
button("Increment").on_press(Message::Increment),
text(model.count.to_string()),
button("Decrement").on_press(Message::Decrement),
].into()
}
fn main() -> iced::Result {
iced::application(Model::default, update, view)
.title("Counter")
.run()
}
This declarative pattern scales beautifully. The key insight: you never mutate the UI directly. Instead, you describe what the UI should look like based on your current state. You also do not have GTK’s issue of a monolithic structure, nor Qt’s problem with non-standard and difficult-to-follow widget methods. React’s shortcomings are also fixed: no prop drilling and no mixing languages. In fact, Iced-rs improves upon React. It keeps the same method of UI building as flexible divs. Beautiful!
Building the app
These principles are sufficient to build a working application. I’ll also integrate the rfd crate, which provides native file picker dialogs. You can find the full source code on GitHub.
The View Layer
Here’s a key excerpt from the view() function that renders the main UI:
fn view(model: &Frozen) -> Element<'_, Message> {
let content: Element<'_, Message> = if !model.kindle_data.is_empty() {
column![
row![ // TOP MENU
button("Delete Entry")
.style(button::danger)
.on_press(Message::DeleteCurrentEntry),
button("Export All")
.style(styles::button_style)
.on_press(Message::ExportAll),
// ... more buttons
]
.spacing(5)
.height(Length::FillPortion(5))
.align_y(Alignment::Center),
row![ // MAIN CONTENT
container(scrollable(
Column::from_vec(
model.kindle_data
.iter()
.map(|book_data|
button(book_data.name.as_str())
.width(Length::Fill)
.on_press(Message::BookSelected(book_data.id))
.into() // <-- Converts Button to Element
)
.collect()
)
))
// ... right pane with quotes
]
].into()
} else {
center(column![
button("Please pick 'My Clippings.txt'")
.on_press(Message::OpenClippings),
]).into()
};
toast::view(content, &model.toasts)
}
Understanding the Widgets
column![]androw![]macros — These are layout containers.column!stacks widgets vertically;row!stacks them horizontally. They’re macros because they accept variable numbers of children with a convenient syntax.button()- Creates a clickable button. The.on_press(Message::X)method attaches an event handler that emits a message when clicked.scrollable()- Wraps content to make it scrollable when it overflows.container()- A generic layout wrapper for padding, styling, and sizing..into()- This is Rust’s type coercion trait method call. Iced’sElement<Message>is a type-erased widget container. Since different widgets (buttons, text, columns) have different types, I call.into()to convert them into the commonElementtype. Without it, the compiler wouldn’t know how to mix different widget types in a collection.
State Management & Message Flow
Notice the cycle:
- User clicks a button → emits
Message::BookSelected(id) update()receives the message → updatesmodel.current_displayed_bookview()re-renders → reads the newmodel.current_displayed_bookand displays its quotes- UI reflects the change → the right pane now shows the selected book’s quotes
This unidirectional data flow makes reasoning about state changes trivial. There’s no hidden state or side effects—everything flows through messages.
The Parser
The UI is just the shell; the real logic lives in the parser. This is where I transform the raw My Clippings.txt file into structured data.
Kindle stores its highlights and notes in a simple My Clippings.txt file on the device. This plain text format makes it perfect for learning Rust—a straightforward string parsing problem that teaches fundamental language concepts without unnecessary complexity.
pub fn parse_clippings(file_path: &str, starting_id: usize) -> io::Result<Vec<BookData>> {
let extracts = retrieve_clippings_from_file(file_path)?;
let mut books: Vec<BookData> = Vec::new();
for extract in extracts {
let (name, author) = get_book_name_and_author(&extract);
let (page, date, quote) = get_extract_quote_metadata(&extract);
let date_added = date.format("%Y-%m-%d %H:%M:%S").to_string();
let book = match books.iter_mut().find(|b| b.name == name) {
Some(b) => b,
None => {
books.push(BookData {
id: starting_id + books.len(),
name,
author,
quotes: Vec::new(),
});
books.last_mut().unwrap()
}
};
let id = book.quotes.len() as i32;
book.quotes.push(Quote {
id,
quote,
page_number: page,
date_added,
});
}
Ok(books)
}
The parser splits My Clippings.txt by the ========== delimiter and processes each extract. For each one, it extracts the book name and author (using regex to handle both parenthetical and dash-separated formats), then pulls the page number, date, and quote text using carefully crafted patterns. Crucially, it groups quotes by book: if a book already exists, new quotes are appended to it; otherwise, a fresh BookData struct is created. This gives us a clean, hierarchical structure ready for display or export.
The Exporter
Once I’ve parsed the data, I need a way to export it in useful formats.
pub fn export_to_md(book_data: &BookData, path: &Path) -> io::Result<()> {
let mut content = format!("# {}\n\n", book_data.name);
if !book_data.author.is_empty() {
content.push_str(&format!("*by {}*\n\n", book_data.author));
}
let blocks: Vec<String> = book_data
.quotes
.iter()
.map(|quote| {
format!(
"---\n> {}\n- Quote ID: {}\n- Page: {}\n- Date Added: {}",
quote.quote, quote.id, quote.page_number, quote.date_added
)
})
.collect();
content.push_str(&blocks.join("\n\n"));
let file_path = path.join(format!("{}.md", sanitize_filename(&book_data.name)));
std::fs::write(file_path, content)
}
pub fn save_quotes(books: &[BookData], path: &Path) -> io::Result<()> {
match bincode::serialize(books) {
Ok(bytes) => fs::write(path, bytes),
Err(err) => Err(io::Error::new(io::ErrorKind::Other, err)),
}
}
The exporter handles two formats: markdown (human-readable) and binary (efficient). The markdown exporter builds a formatted document with the book title, author, and quotes separated by dividers—perfect for reading or sharing. For persistence, save_quotes() serializes the entire BookData vector using bincode, a compact binary format. This lets users save their parsed data and reload it later without re-parsing the Clippings file. The sanitize_filename() helper prevents filesystem errors by replacing invalid characters.
Conclusion
Once you know a programming language, you do not need to go through any tutorials of other programming languages. All you need is practice, practice, practice. You already have the problem-solving skills necessary; what you lack is knowledge. You gain that knowledge not by watching Rust programming tutorials but by doing your projects.