The Documentation Gap
If you search for "GTK4 Rust tutorial," you'll find a handful of blog posts and the official gtk-rs bindings documentation. Compare that to GTK4 Python or C, where there are hundreds of tutorials, books, and Stack Overflow answers. Building GTK4 apps in Rust means constantly translating C examples into Rust idiom — and the translation isn't always obvious.
This post is the reference I wish I had when I started. It covers the patterns that took me weeks to figure out: how to share state across GTK4 widgets, how to bridge tokio into glib's event loop, how to handle signals properly, and how to make your app feel native on GNOME.
GTK4 Signals and Rust's Borrow Checker
GTK4 uses a signal-based architecture. When you click a button, it emits a clicked signal. When you change a text entry, it emits a changed signal. In C, you connect a callback function to the signal. In Rust, the callback must implement the Fn trait — which means it can't capture mutable references.
This is the first wall you hit. In Python GTK4, you can do:
def on_button_clicked(button):
self.label.set_text("Clicked!")
In Rust, you can't do the equivalent because self would be a mutable borrow captured by the closure, and GTK4 might call the signal handler from a different context:
// This DOESN'T compile
button.connect_clicked(move |_| {
label.set_text("Clicked!"); // Error: can't move `label` into closure
});
The solution is Rc<RefCell<>> — the standard shared state pattern in GTK4 Rust apps:
use std::cell::RefCell;
use std::rc::Rc;
use gtk::prelude::*;
let label = Rc::new(RefCell::new(gtk::Label::new(None)));
let label_clone = label.clone();
button.connect_clicked(move |_| {
label_clone.borrow().set_text("Clicked!");
});
// Later, you can update the label from anywhere that holds a clone
label.borrow().set_text("Updated from somewhere else");
Rc gives you shared ownership (multiple variables can own the same data). RefCell gives you interior mutability (you can mutate the data even through a shared reference). Together, they let you share state between signal handlers without fighting the borrow checker.
The pattern scales. For a complex app with multiple widgets sharing state:
struct AppState {
label: gtk::Label,
entry: gtk::Entry,
button: gtk::Button,
counter: i32,
}
let state = Rc::new(RefCell::new(AppState {
label: gtk::Label::new(None),
entry: gtk::Entry::new(),
button: gtk::Button::with_label("Increment"),
counter: 0,
}));
let state_clone = state.clone();
state.borrow().button.connect_clicked(move |_| {
let mut s = state_clone.borrow_mut();
s.counter += 1;
s.label.set_text(&format!("Count: {}", s.counter));
});
Every signal handler clones the Rc, borrows the state, and updates what it needs. It's verbose but correct. The borrow checker guarantees no data races at compile time.
Bridging Tokio into GLib's Event Loop
GTK4 runs its own event loop via glib. Tokio runs its own async runtime. You need both — GTK4 for the UI, tokio for async operations like network requests or file I/O. The challenge is running them without blocking each other.
The bridge is glib::spawn_future_local. It runs a Rust future on glib's event loop, which means it runs on the main thread but yields control back to GTK4 between .await points:
use glib::spawn_future_local;
use futures::channel::mpsc;
fn setup_async_handler(state: Rc<RefCell<AppState>>) {
let (tx, mut rx) = mpsc::channel::<AppEvent>(32);
// Spawn a future on glib's event loop
spawn_future_local(async move {
while let Some(event) = rx.next().await {
match event {
AppEvent::DataLoaded(data) => {
let mut s = state.borrow_mut();
s.label.set_text(&data);
}
AppEvent::Error(msg) => {
let mut s = state.borrow_mut();
s.label.set_text(&format!("Error: {}", msg));
}
}
}
});
// Store the sender for use from other threads
state.borrow_mut().event_tx = Some(tx);
}
For operations that genuinely need tokio (like running a TCP server or making concurrent HTTP requests), you spawn tokio tasks and use channels to send results back to glib:
use tokio::sync::mpsc;
fn spawn_tokio_task(tx: glib::Sender<AppEvent>) {
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// Do async work on tokio
let result = fetch_data().await;
// Send result back to glib's event loop
tx.send(AppEvent::DataLoaded(result)).ok();
});
});
}
The key insight: tokio tasks run on tokio's thread pool, and GTK4 runs on the main thread. Never mix them directly. Always use a channel to bridge between the two runtimes.
Custom Widgets with Composite Templates
GTK4's composite templates let you define widget layout in XML and bind it to Rust code. This is the GTK4 equivalent of React components with JSX — you separate structure from behavior.
First, define the template XML:
<!-- src/ui/property_card.xml -->
<interface>
<template class="PropertyCard" parent="gtk::Box">
<property name="orientation">vertical</property>
<property name="spacing">8</property>
<child>
<object class="gtk::Picture" id="image">
<property name="height-request">200</property>
<property name="content-fit">cover</property>
</object>
</child>
<child>
<object class="gtk::Label" id="title">
<property name="xalign">0</property>
<style>
<class name="heading"/>
</style>
</object>
</child>
<child>
<object class="gtk::Label" id="price">
<property name="xalign">0</property>
<style>
<class name="price"/>
</style>
</object>
</child>
</template>
</interface>
Then bind it to Rust:
use gtk::prelude::*;
use gtk::subclass::prelude::*;
mod imp {
use super::*;
use gtk::glib::subclass::prelude::*;
use gtk::CompositeTemplate;
#[derive(Default, CompositeTemplate)]
#[template(resource = "/com/app/property_card.xml")]
pub struct PropertyCard {
#[template_child]
image: gtk::Picture,
#[template_child]
title: gtk::Label,
#[template_child]
price: gtk::Label,
}
#[glib::object_subclass]
impl ObjectSubclass for PropertyCard {
const NAME: &'static str = "PropertyCard";
type Type = super::PropertyCard;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
}
}
impl ObjectImpl for PropertyCard {}
impl WidgetImpl for PropertyCard {}
impl BoxImpl for PropertyCard {}
}
glib::wrapper! {
pub struct PropertyCard(ObjectSubclass<imp::PropertyCard>)
@extends gtk::Box, gtk::Widget;
}
impl PropertyCard {
pub fn new(property: &Property) -> Self {
let card: Self = glib::Object::new();
card.imp().title.set_text(&property.name);
card.imp().price.set_text(&format!("৳{}", property.price));
card
}
}
The composite template approach keeps your Rust code clean. The XML handles layout, the Rust code handles behavior. And GTK4 re-renders the widget automatically when properties change — no manual DOM updates.
Making It Feel Native with libadwaita
GNOME has its own design language, and libadwaita is the library that implements it. If your GTK4 app uses libadwaita, it automatically gets the right fonts, colors, spacing, and behaviors for GNOME. It's the difference between an app that "runs on Linux" and an app that "belongs on GNOME."
use libadwaita::prelude::*;
fn main() {
let app = libadwaita::Application::builder()
.application_id("com.example.myapp")
.build();
app.connect_activate(|app| {
let window = libadwaita::ApplicationWindow::builder()
.application(app)
.title("My App")
.default_width(800)
.default_height(600)
.build();
// Use Adwaita's styles
let toolbar = libadwaita::ToolbarView::new();
let header = libadwaita::HeaderBar::new();
toolbar.add_top_bar(&header);
window.set_content(Some(&toolbar));
window.present();
});
app.run();
}
libadwaita handles dark mode automatically. It respects the user's GNOME theme. It provides standard dialogs, toast notifications, and adaptive layouts that work across phone and desktop form factors. Using it is not optional if you want your app to feel professional on GNOME.
Testing GTK4 Apps
Testing GUI apps is inherently harder than testing libraries. GTK4 widgets need a display server, which means you can't run unit tests in a headless CI environment without a virtual display.
My approach: separate the logic from the UI as much as possible, then test the logic independently.
// Game logic — no GTK4 dependency, testable anywhere
fn calculate_emi(principal: f64, rate: f64, years: u32) -> f64 {
let monthly_rate = rate / 100.0 / 12.0;
let months = years as f64 * 12.0;
(principal * monthly_rate * (1.0 + monthly_rate).powf(months))
/ ((1.0 + monthly_rate).powf(months) - 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emi_calculation() {
let emi = calculate_emi(1_000_000.0, 8.5, 20);
assert!((emi - 8678.0).abs() < 1.0);
}
}
For UI tests, I use xdg-run with a virtual framebuffer:
# In CI
Xvfb :99 -screen 0 1024x768x24 &
export DISPLAY=:99
cargo test
But honestly, most of my testing is manual. GTK4 apps are visual — you need to see them to know if they're right. Automated tests catch crashes and logic bugs, but they don't catch "this padding looks wrong" or "this animation feels off."
The Rust + GTK4 Workflow
Here's the development workflow I settled on:
- Define the UI in composite template XML
- Implement the widget logic in Rust with
ObjectSubclass - Connect signal handlers using
Rc<RefCell<>>for shared state - Run async operations in tokio, bridge results to glib via channels
- Use libadwaita for native GNOME styling
- Test logic with
cargo test, test UI manually
It's not the fastest development cycle. GTK4's learning curve is steep, and the Rust bindings add another layer of complexity. But the result is a native Linux app that starts in 50ms, uses 30MB of RAM, and responds to system themes and settings automatically.
If you're building a desktop Linux app and considering Electron, try Rust + GTK4 first. The initial investment is higher, but the result is an app that your users will actually enjoy using.
