Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Mark strings in source code and UI files as translatable
  2. Extract strings into a template file (.pot)
  3. Translate strings into language-specific files (.po)
  4. 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
To-Do app in German

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.