Internationalization
Now that we have Meson set up, we can add internationalization to our To-Do app. We want users to be able to use the app in their own language. With GTK, gettext is the standard system for this.
How Gettext Works
Gettext uses message catalogs to store translations. The workflow looks like this:
- Mark strings in source code and UI files as translatable
- Extract strings into a template file (
.pot) - Translate strings into language-specific files (
.po) - Compile translations into binary format (
.mo) during build
GTK already handles translations for strings marked with translatable="yes" in UI templates.
However, strings defined in Rust code (like our dialog messages) require explicit gettext calls.
Let’s set this up!
Project Structure
We extend our project with a po/ directory for translation files:
├── po/
│ ├── LINGUAS # List of supported languages
│ ├── POTFILES.in # Files containing translatable strings
│ ├── meson.build # Meson i18n integration
│ └── de.po # German translation
Setting Up Translations
The po Directory
We create po/meson.build which tells Meson to handle translations using the glib preset.
The preset knows how to extract strings from .ui files and Rust source code.
Filename: listings/todo/10/po/meson.build
i18n.gettext(meson.project_name(), preset: 'glib')
POTFILES.in lists all files that contain translatable strings.
Filename: listings/todo/10/po/POTFILES.in
data/org.gtk_rs.Todo10.desktop.in.in
data/resources/window.ui
data/resources/shortcuts.ui
src/window/mod.rs
LINGUAS lists the language codes of available translations.
In our case, we’ll only add German (de), the mother tongue of the author of this book.
Filename: listings/todo/10/po/LINGUAS
de
Updating meson.build
We import the i18n module and add the po subdirectory.
Filename: listings/todo/10/meson.build
# Set up the project
project('todo-10', 'rust', version: '0.1.0', meson_version: '>= 1.4')
# Import GNOME and i18n modules
gnome = import('gnome')
i18n = import('i18n')
# Set the base_id
base_id = 'org.gtk_rs.Todo10'
# Define variables depending on the current profile
is_devel = get_option('profile') == 'development'
if is_devel
profile = 'Devel'
application_id = '@0@.@1@'.format(base_id, profile)
else
profile = ''
application_id = base_id
endif
# Set a couple of useful variables
bindir = get_option('prefix') / get_option('bindir')
localedir = get_option('prefix') / get_option('localedir')
datadir = get_option('prefix') / get_option('datadir')
pkgdatadir = datadir / meson.project_name()
# Enter these subdirectories and execute the meson.build files in them
subdir('data')
subdir('po')
subdir('src')
# Execute these tasks after installing
gnome.post_install(
gtk_update_icon_cache: true,
glib_compile_schemas: true,
update_desktop_database: true,
)
We also pass LOCALEDIR and GETTEXT_PACKAGE to the Rust build, so our code knows where to find translations.
Filename: listings/todo/10/src/meson.build
cargo = find_program('cargo')
cargo_options = ['--manifest-path', meson.project_source_root() / 'Cargo.toml']
cargo_options += ['--target-dir', meson.project_build_root() / 'target']
if not is_devel
cargo_options += ['--release']
rust_target = 'release'
else
rust_target = 'debug'
endif
custom_target(
'cargo-build',
build_by_default: true,
build_always_stale: true,
output: meson.project_name(),
console: true,
install: true,
install_dir: bindir,
depends: resources,
env: {
'CARGO_HOME': meson.project_build_root() / 'cargo-home',
'APP_ID': application_id,
'GETTEXT_PACKAGE': meson.project_name(),
'LOCALEDIR': localedir,
'RESOURCES_FILE': pkgdatadir / 'resources.gresource',
},
command: [
cargo,
'build',
cargo_options,
'&&',
'cp',
meson.project_build_root() / 'target' / rust_target / meson.project_name(),
'@OUTPUT@',
],
)
Translations in Rust Code
So far so good.
The build system is ready, but we haven’t touched the Rust code yet.
How do we actually translate strings that are defined in code rather than in .ui files?
Adding the Dependency
We add gettext-rs to our dependencies.
cargo add gettext-rs
The Config Module
We extend config.rs to provide the locale directory and gettext package name:
Filename: listings/todo/10/src/config.rs
pub const APP_ID: Option<&str> = option_env!("APP_ID");
pub const GETTEXT_PACKAGE: Option<&str> = option_env!("GETTEXT_PACKAGE");
pub const LOCALEDIR: Option<&str> = option_env!("LOCALEDIR");
pub const RESOURCES_FILE: Option<&str> = option_env!("RESOURCES_FILE");
pub fn app_id() -> &'static str {
APP_ID.expect("APP_ID env var not set at compile time")
}
pub fn gettext_package() -> &'static str {
GETTEXT_PACKAGE.expect("GETTEXT_PACKAGE env var not set at compile time")
}
pub fn localedir() -> &'static str {
LOCALEDIR.expect("LOCALEDIR env var not set at compile time")
}
pub fn resources_file() -> &'static str {
RESOURCES_FILE.expect("RESOURCES_FILE env var not set at compile time")
}
Initializing Gettext
We create an i18n module to initialize the translation system.
This must happen early, before any translatable strings are used.
Filename: listings/todo/10/src/i18n.rs
use gettextrs::{
LocaleCategory, bind_textdomain_codeset, bindtextdomain, setlocale, textdomain,
};
use crate::config;
pub fn init() {
// Prepare i18n
setlocale(LocaleCategory::LcAll, "");
bindtextdomain(config::gettext_package(), config::localedir())
.expect("Unable to bind the text domain");
bind_textdomain_codeset(config::gettext_package(), "UTF-8")
.expect("Unable to set text domain encoding");
textdomain(config::gettext_package()).expect("Unable to switch to the text domain");
}
We call i18n::init() in main.rs before loading resources:
Filename: listings/todo/10/src/main.rs
mod collection_object;
mod config;
mod i18n;
mod task_object;
mod utils;
mod window;
use adw::prelude::*;
use gtk::{gio, glib};
use window::Window;
fn main() -> glib::ExitCode {
// Initialize locale and translations
i18n::init();
// Load resources from installed location
let res = gio::Resource::load(config::resources_file())
.expect("Could not load gresource file");
gio::resources_register(&res);
// Create a new application
let app = adw::Application::builder()
.application_id(config::app_id())
.build();
// Connect to signals
app.connect_startup(setup_shortcuts);
app.connect_activate(build_ui);
// Run the application
app.run()
}
fn setup_shortcuts(app: &adw::Application) {
app.set_accels_for_action("win.filter('All')", &["<Ctrl>a"]);
app.set_accels_for_action("win.filter('Open')", &["<Ctrl>o"]);
app.set_accels_for_action("win.filter('Done')", &["<Ctrl>d"]);
}
fn build_ui(app: &adw::Application) {
// Create a new custom window and present it
let window = Window::new(app);
window.present();
}
Marking Strings for Translation
Now we can use gettext() to mark strings in our Rust code.
We wrap each user-visible string in the “New Collection” dialog:
Filename: listings/todo/10/src/window/mod.rs
mod imp;
use std::fs::File;
use adw::prelude::*;
use adw::subclass::prelude::*;
use adw::{ActionRow, AlertDialog, ResponseAppearance};
use gettextrs::gettext;
use gio::Settings;
use glib::{Object, clone};
use gtk::{
Align, CheckButton, CustomFilter, Entry, FilterListModel, Label, ListBoxRow,
NoSelection, gio, glib, pango,
};
use crate::collection_object::{CollectionData, CollectionObject};
use crate::config;
use crate::task_object::TaskObject;
use crate::utils::data_path;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable,
gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager;
}
impl Window {
pub fn new(app: &adw::Application) -> Self {
// Create new window
Object::builder().property("application", app).build()
}
fn setup_settings(&self) {
let settings = Settings::new(config::app_id());
self.imp()
.settings
.set(settings)
.expect("`settings` should not be set before calling `setup_settings`.");
}
fn settings(&self) -> &Settings {
self.imp()
.settings
.get()
.expect("`settings` should be set in `setup_settings`.")
}
fn tasks(&self) -> gio::ListStore {
self.current_collection().tasks()
}
fn current_collection(&self) -> CollectionObject {
self.imp()
.current_collection
.borrow()
.clone()
.expect("`current_collection` should be set in `set_current_collections`.")
}
fn collections(&self) -> gio::ListStore {
self.imp()
.collections
.get()
.expect("`collections` should be set in `setup_collections`.")
.clone()
}
fn set_filter(&self) {
self.imp()
.current_filter_model
.borrow()
.clone()
.expect("`current_filter_model` should be set in `set_current_collection`.")
.set_filter(self.filter().as_ref());
}
fn filter(&self) -> Option<CustomFilter> {
// Get filter state from settings
let filter_state: String = self.settings().get("filter");
// Create custom filters
let filter_open = CustomFilter::new(|obj| {
// Get `TaskObject` from `glib::Object`
let task_object = obj
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
// Only allow completed tasks
!task_object.is_completed()
});
let filter_done = CustomFilter::new(|obj| {
// Get `TaskObject` from `glib::Object`
let task_object = obj
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
// Only allow done tasks
task_object.is_completed()
});
// Return the correct filter
match filter_state.as_str() {
"All" => None,
"Open" => Some(filter_open),
"Done" => Some(filter_done),
_ => unreachable!(),
}
}
fn setup_collections(&self) {
let collections = gio::ListStore::new::<CollectionObject>();
self.imp()
.collections
.set(collections.clone())
.expect("Could not set collections");
self.imp().collections_list.bind_model(
Some(&collections),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let collection_object = obj
.downcast_ref()
.expect("The object should be of type `CollectionObject`.");
let row = window.create_collection_row(collection_object);
row.upcast()
}
),
)
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<CollectionData> = serde_json::from_reader(file)
.expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<CollectionData>` to `Vec<CollectionObject>`
let collections: Vec<CollectionObject> = backup_data
.into_iter()
.map(CollectionObject::from_collection_data)
.collect();
// Insert restored objects into model
self.collections().extend_from_slice(&collections);
// Set first collection as current
if let Some(first_collection) = collections.first() {
self.set_current_collection(first_collection.clone());
}
}
}
fn create_collection_row(
&self,
collection_object: &CollectionObject,
) -> ListBoxRow {
let label = Label::builder()
.ellipsize(pango::EllipsizeMode::End)
.xalign(0.0)
.build();
collection_object
.bind_property("title", &label, "label")
.sync_create()
.build();
ListBoxRow::builder().child(&label).build()
}
fn set_current_collection(&self, collection: CollectionObject) {
// Wrap model with filter and selection and pass it to the list box
let tasks = collection.tasks();
let filter_model = FilterListModel::new(Some(tasks.clone()), self.filter());
let selection_model = NoSelection::new(Some(filter_model.clone()));
self.imp().tasks_list.bind_model(
Some(&selection_model),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let task_object = obj
.downcast_ref()
.expect("The object should be of type `TaskObject`.");
let row = window.create_task_row(task_object);
row.upcast()
}
),
);
// Store filter model
self.imp().current_filter_model.replace(Some(filter_model));
// If present, disconnect old `tasks_changed` handler
if let Some(handler_id) = self.imp().tasks_changed_handler_id.take() {
self.tasks().disconnect(handler_id);
}
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&tasks);
let tasks_changed_handler_id = tasks.connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
self.imp()
.tasks_changed_handler_id
.replace(Some(tasks_changed_handler_id));
// Set current tasks
self.imp().current_collection.replace(Some(collection));
self.select_collection_row();
}
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn select_collection_row(&self) {
if let Some(index) = self.collections().find(&self.current_collection()) {
let row = self.imp().collections_list.row_at_index(index as i32);
self.imp().collections_list.select_row(row.as_ref());
}
}
fn create_task_row(&self, task_object: &TaskObject) -> ActionRow {
// Create check button
let check_button = CheckButton::builder()
.valign(Align::Center)
.can_focus(false)
.build();
// Create row
let row = ActionRow::builder()
.activatable_widget(&check_button)
.build();
row.add_prefix(&check_button);
// Bind properties
task_object
.bind_property("completed", &check_button, "active")
.bidirectional()
.sync_create()
.build();
task_object
.bind_property("content", &row, "title")
.sync_create()
.build();
// Return row
row
}
fn setup_callbacks(&self) {
// Setup callback for activation of the entry
self.imp().entry.connect_activate(clone!(
#[weak(rename_to = window)]
self,
move |_| {
window.new_task();
}
));
// Setup callback for clicking (and the releasing) the icon of the entry
self.imp().entry.connect_icon_release(clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.new_task();
}
));
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.set_filter();
}
),
);
// Setup callback when items of collections change
self.set_stack();
self.collections().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |_, _, _, _| {
window.set_stack();
}
));
// Setup callback for activating a row of collections list
self.imp().collections_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
let index = row.index();
let selected_collection = window
.collections()
.item(index as u32)
.expect("There needs to be an object at this position.")
.downcast::<CollectionObject>()
.expect("The object needs to be a `CollectionObject`.");
window.set_current_collection(selected_collection);
window.imp().split_view.set_show_content(true);
}
));
}
fn set_stack(&self) {
if self.collections().n_items() > 0 {
self.imp().stack.set_visible_child_name("main");
} else {
self.imp().stack.set_visible_child_name("placeholder");
}
}
fn new_task(&self) {
// Get content from entry and clear it
let buffer = self.imp().entry.buffer();
let content = buffer.text().to_string();
if content.is_empty() {
return;
}
buffer.set_text("");
// Add new task to model
let task = TaskObject::new(false, content);
self.tasks().append(&task);
}
fn setup_actions(&self) {
// Create action from key "filter" and add to action group "win"
let action_filter = self.settings().create_action("filter");
self.add_action(&action_filter);
}
fn remove_done_tasks(&self) {
let tasks = self.tasks();
let mut position = 0;
while let Some(item) = tasks.item(position) {
// Get `TaskObject` from `glib::Object`
let task_object = item
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
if task_object.is_completed() {
tasks.remove(position);
} else {
position += 1;
}
}
}
async fn new_collection(&self) {
// Create entry
let entry = Entry::builder()
.placeholder_text(gettext("Name"))
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = AlertDialog::builder()
.heading(gettext("New Collection"))
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog.add_responses(&[
(cancel_response, &gettext("Cancel")),
(create_response, &gettext("Create")),
]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
dialog.set_response_enabled(create_response, !empty);
if empty {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(Some(self)).await;
// Return if the user chose `cancel_response`
if response == cancel_response {
return;
}
// Create a new list store
let tasks = gio::ListStore::new::<TaskObject>();
// Create a new collection object from the title the user provided
let title = entry.text().to_string();
let collection = CollectionObject::new(&title, tasks);
// Add new collection object and set current tasks
self.collections().append(&collection);
self.set_current_collection(collection);
// Show the content
self.imp().split_view.set_show_content(true);
}
}
The gettext() function looks up the translation at runtime.
If no translation exists, it just returns the original string.
Desktop File Translation
Desktop files also support translations.
We prefix translatable keys with an underscore.
Also since it is now templated with both Meson and gettext, we change the file extension from org.gtk_rs.Todo10.desktop.in to org.gtk_rs.Todo10.desktop.in.in.
Filename: listings/todo/10/data/org.gtk_rs.Todo10.desktop.in.in
[Desktop Entry]
Name=Todo 10
_Comment=Keep track of tasks
Exec=todo-10
Icon=@APP_ID@
Terminal=false
Type=Application
DBusActivatable=true
Categories=GNOME;GTK;Utility;
Meson’s i18n.merge_file() processes _Comment and merges in the translations.
Creating a Translation
The Translation File
Translation files use the .po format.
This is how a small section of the file looks like:
Filename: listings/todo/10/po/de.po
#: data/resources/window.ui:134
msgid "Main Menu"
msgstr "Hauptmenü"
#: data/resources/window.ui:153
msgid "Enter a Task…"
msgstr "Aufgabe eingeben…"
Each entry has:
- A comment showing where the string appears
msgid: the original string (must match exactly)msgstr: the translated string
Generating the Template
To create a .pot template file with all translatable strings:
meson compile -C builddir todo-10-pot
Translators can use this template to create new .po files.
Tools like Poedit or GNOME Translation Editor make this easier.
Building and Testing
Build and install as usual:
meson setup builddir -Dprofile=development --prefix=~/.local
meson install -C builddir
To test a specific language, set the LANGUAGE environment variable:
LANGUAGE=de todo-10

That was it! Our To-Do app can now speak German. For projects with many contributors, translation platforms like Weblate or Damned Lies help coordinate the work.