Der „FileDialog“ in GTK 4 mit Rust

In sehr loser Folge schreibe ich auch mal über technische Themen, wenn mich ein solches gerade beschäftigt. In diesem Falle habe ich ein wenig an einer GUI-Anwendung mit Rust und GTK 4 gebastelt, und bin dabei darüber gestolpert, dass der FileChooserDialog in der aktuellen Version 4.10 als „deprecated“ markiert worden ist. Ersetzt wird er durch FileDialog. Der FileDialog verwendet, im Gegensatz zum FileChooserDialog, keinen Gtk-eigenen, selbstgebauten Dialog, sondern verwendet den Dateiauswahl-Dialog der jeweiligen Desktop-Umgebung. Außerdem folgt der FileDialog dem GIO async pattern, das wohl als die Zukunft der asynchronen Programmierung mit GTK gedacht ist.

Leider hat sich die offizielle Dokumentation für mich eher als verwirrend, denn als hilfreich erwiesen, und ich musste einige Zeit herumprobieren, bis ich es soweit hatte, dass es für mich funktionierte. Da ich mir vorstellen könnte, dass es anderen auch so geht, kommt hier ein kleines Tutorial. Wir wollen eine kleine App basteln, in der wir über den FileDialog Dateien auswählen können, die dann an die Umgebung durchgereicht werden, um von dieser geöffnet zu werden. Das Ganze wollen wir dann einmal synchron und asynchron. Das bedeutet hier allerdings nur, dass wir in der zweiten Version rusteigene Bordmittel zur asynchronen Programmierung, also die Schlüsselwörter „async“ und „.await“ einsetzen werden. Im Übrigen unterscheiden sich beide Programme nicht in ihrem Verhalten. Das fertige Programm ist in meinem Github-Repository abrufbar: synchron und asynchron.

Das fertige Programm mit geöffnetem Menü
Weiterlesen: Der „FileDialog“ in GTK 4 mit Rust

Vorbereitung

Benötigt werden eine Rust-Toolchain und GTK. Beide sind für so ziemlich alle gebräuchlichen Desktop-Betriebssysteme, einschließlich macOS, Windows und diversen BSD-Derivaten und (GNU/)Linux-Distributionen verfügbar. Eine Installationsanleitung für Rust findest du in dem Buch „The Rust Programming Language„, welches dir zugleich zur Lektüre empfohlen sei, wenn du noch eine Einführung in die Programmiersprache Rust als solche benötigst. Es ist nämlich nicht der Anspruch des Tutorials, eine solche zu liefern. Vielmehr setze ich voraus, dass entsprechendes Grundlagenwissen bereits vorhanden ist.

Das Projekt, das GTK für Rust zugänglich macht, heißt gtk-rs. Während die benötigten Crates mit den entsprechenden Language Bindings für Rust auf dem für Rust üblichen Wege via Cargo bei Bedarf heruntergeladen und kompiliert werden, müssen GTK 4 und einige andere Bibliotheken, die GTK 4 benötigt, um zu funktionieren, vorher installiert werden. Auch zu gtk-rs gibt es ein Buch, und auch in diesem Buch gibt es eine entsprechende Installationsanleitung. Auch dieses Buch sei als Einstiegslektüre durchaus empfohlen. Das vorliegende Tutorial versteht sich nicht als Alternative zu diesem Buch. Es geht vielmehr darum, ein paar spezielle Punkte anhand eines konkreten Beispiels zu veranschaulichen, die mir zumindest alleine aufgrund des Buchs nicht so ganz klar waren.

Los geht’s: Projekt anlegen

Wir beginnen mit der synchronen Fassung unseres Projekts:

$ cargo new file_dialog_demo

Jetzt müssen wir unsere Dependencies ergänzen. Wir brauchen „open“ für das Öffnen der Dateien und natürlich gtk4, denn darum geht es hier ja. Bei gtk4 ist es wichtig, dass wir als feature explizit v4.10 angeben, da erst in dieser Version der FileDialog enthalten ist:

$ cargo add open
$ cargo add gtk4 --rename gtk --features v4_10

4.10 ist zum Zeitpunkt des Erstellens dieses Beitrages die neueste Version. Wenn du dies liest, gibt es vielleicht schon eine neuere Version; es dürfte dann unproblematisch möglich sein, auch diese zu verwenden.

Das User Interface

Jetzt bauen wir das User Interface (UI). Wir wollen ein einfaches Fenster mit einem sogenannten Hamburger-Menü, wie das heute halt gerne so gemacht wird. Über dieses Menü kann dann auch der FileDialog aufgerufen werden. In dem Fenster selbst wollen wir ein GktLabel, welches uns eine Rückmeldung über die Aktivitäten unseres Programms geben soll (also praktisch als eine Art Statusleiste fungiert). In der Dokumentation wird empfohlen. Es gilt als guter Stil, die Logik eines Programms von seinem UI zu trennen. Dazu bieten GUI-Frameworks häufig die Möglichkeit an, das UI in einer sog. Markup Language zu beschreiben. Im Falle von GTK müssen wir leider XML benutzen. TOML oder sowas wäre freilich hübscher und bequemer, aber das ist halt leider kein Wunschkonzert. Wir erstellen im Wurzelverzeichnis unseres Projekts ein neues Unterverzeicnis „resources“:

$ mkdir resources

In diesem Unterverzeichnis werden zwei XML-Dateien benötigt: resources.gresource.xml und window.ui. resources.gresource.xml sieht so aus:

<?xml version="1.0" encoding="UTF-8"?>
<gresources>
    <gresource prefix="/org/keienb/file_dialog_demo">
        <file compressed="true" preprocess="xml-stripblanks">window.ui</file>
    </gresource>
</gresources>

Die Datei window.ui hingegen enthält die eigentliche Beschreibung unseres UI. GTK an sich ist in C in geschrieben. Diese XML-Dateien sind unabhängig von der Programmiersprache immer gleich. Deswegen haben die Klassen die Namen, die sie auch im Original in C haben.

<?xml version="1.0" encoding="UTF-8"?>
<interface>
    <menu id="main-menu">
        <item>
            <attribute name="label" translatable="yes">_Open</attribute>
            <attribute name="action">win.open</attribute>
        </item>
        <item>
            <attribute name="label" translatable="yes">_Close window</attribute>
            <attribute name="action">window.close</attribute>
        </item>
    </menu>
    <object class="GtkApplicationWindow" id="app_window">
        <property name="default_width">800</property>
        <property name="default_height">400</property>
        <property name="title">File Dialog Demo (synchron)</property>
        <child type="titlebar">
            <object class="GtkHeaderBar">
                <child type="end">
                    <object class="GtkMenuButton">
                        <property name="icon-name">open-menu-symbolic</property>
                        <property name="menu-model">main-menu</property>
                        <property name="tooltip-text" translatable="yes">Main Menu</property>
                    </object>
                </child>
            </object>
        </child>
        <child>
            <object class="GtkLabel" id="label">
                <property name="label">Hello, World!</property>
            </object>
        </child>
    </object>
</interface>

Als Nächstes müssen wir uns darum kümmern, dass die Definition unseres UI kompiliert und in unser noch zu erstellendes Binary gelinkt wird. Dazu legen wir, wiederum im Wurzelverzeichnis unseres Projekts, eine Datei build.rs mit folgendem Inhalt an:

fn main () {
    glib_build_tools::compile_resources(
        &["resources"],
        "resources/resources.gresource.xml",
        "org_keienb_file_dialog_demo.gresource"
    );
}

Sodann fügen wir mit folgendem Befehl die glib-build-tools als build dependency unserem Projekt hinzu:

$ cargo add glib-build-tools --build

Und nun zum Wesentlichen

Jetzt kommen wir zur Sache und befüllen unsere main.rs mit der eigentlichen Funktionalität. Vorab einmal hier die komplette fertige main.rs der synchronen Fassung, die wir sodann von oben nach unten durchgehen:

use open;
use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, Builder, FileDialog, Label, gio, glib};
use glib::clone;

const APP_ID: &str = "org.keienb.file_dialog_demo";

fn main() -> glib::ExitCode {
    gio::resources_register_include!("org_keienb_file_dialog_demo.gresource")
        .expect("Failed to register resources.");
    let app = Application::builder().application_id(APP_ID).build();
    app.connect_startup(setup_shortcuts);
    app.connect_activate(build_ui);
    app.run()
}

fn setup_shortcuts(app: &Application) {
    app.set_accels_for_action("win.open", &["<Ctrl>o"]);
    app.set_accels_for_action("window.close", &["<Ctrl>q"]);
}

fn build_ui(app: &Application) {
    let builder = Builder::from_resource("/org/keienb/file_dialog_demo/window.ui");
    let window :ApplicationWindow = builder.object("app_window")
        .expect("Failed to load application window from resource");
    setup_actions(&window);
    app.add_window(&window);
    window.present()
}

fn setup_actions(window: &ApplicationWindow) {
    let action_open = gio::SimpleAction::new("open", None);
    action_open.connect_activate(clone!(@weak window => move |_, _| {
        let file_dialog = FileDialog::builder().modal(false).build();
        file_dialog.open(Some(&window), None::<&gio::Cancellable>, clone!(@weak window => move |result| {
            let text :String;
            match result {
                 Ok(file) => {
                    let path = file.path().unwrap();
                    let _ = open::that(&path);
                    text = format!("Opening: {path:#?}")
                 },
                 Err(e) => text = format!("Error: {e:#?}")
            };
            let label = window.child()
                .unwrap()
                .downcast::<Label>()
                .unwrap();
            label.set_text(&text);
        }));
    }));
    window.add_action(&action_open);
}

In den Zeilen 1 bis 4 binden wir die benötigten Crates und Klassen ein.

In Zeile 6 geben wir die ID unseres Programms an.

Zeile 8: main()

In Zeile 8 beginnt die main-Funktion. Sie hat einen Rückgabewert vom Typ glib::ExitCode. Damit wird dem aufrufenden Programm, also beispielsweise der Shell, zurückgemeldet, ob das Programm durchlief, oder ob Probleme auftraten. In den Zeilen 9 und 10 registrieren wir die Ressourcen, also die XML-Dateien mit der Beschreibung des UI. Lassen sich die Ressourcen nicht laden, ist ein weiterer Ablauf des Programms sinnvollerweise nicht mehr möglich, weil das UI nicht mehr aufgebaut werden kann. Der Aufruf von expect() wird das Programm daher an dieser Stelle mit der Fehlermeldung „Failed to register resources.“ abbrechen lassen.

In Zeile 11 erzeugen wir mit Hilfe der Klage gtk4::builders::ApplicationBuilder ein Objekt der Klasse gtk4::Application. Diese Vorgehensweise folgt dem Builder Pattern, wie hier beschrieben.

GTK arbeitet stark mit Vererbung. Das ist einerseits technisch interessant, weil GTK in C geschrieben ist, mithin in einer Sprache, die mit objektorientierter Programmierung eigentlich nicht viel zu tun hat. Falls es dich interessiert, wie man in einer Sprache, mit der man gar nicht objektorientiert programmieren kann, objektorientiert programmieren kann, magst du vielleicht einen Blick in das entsprechende Tutorial werfen. Andererseits zwingt es aber auch in der Arbeit mit Rust zu gewissen Verrenkungen. Denn die Rust-Entwickler*innen haben sich bewusst dagegen entschieden, sämtliche Konzepte, die man so unter dem Oberbegriff „Objektorientierung“ zusammenfassen könnte, zu übernehmen. Insbesondere gibt es in Rust eben gerade bewusst keine Vererbung. Damit haben wir also in Rust ein ähnliches Problem, wie auch in C. Auch hier haben schlaue Menschen Lösungen entwickelt, dazu steht einiges mehr im Buch. Für den Moment reicht es aber, zu wissen, dass Gtk.Application eine abgeleitete Klasse der Klasse gio.Application ist. Von dieser erbt sie die Signale „startup“ und „activate“. In Zeile 12 verbinden wir die Funktion „setup_shortcuts“ mit dem „startup“-Signal und in Zeile 13 die Funktion „build_ui“ mit dem „activate“-Signal.

Damit ist freilich noch nicht geklärt, was diese Signale dann tatsächlich auslöst, die dann aufgrund dieser Verbindungen dazu führen, diese Funktionen auch tatsächlich aufzurufen. Da die main()-Funktion auch nur noch eine Zeile hat, nämlich den Aufruf von gtk4::Application::run() in Zeile 14, ist ja eigentlich klar, dass sich die Antwort auf diese Frage dort verstecken muss. Und so ist es auch: Die zugrundeliegende C-Funktion g_application_run() ruft die Funktionen g_application_register() und g_applcation_activate() auf, die dann das „startup“- und das „activate“-Signal werfen. Außerdem werden etwaige Kommondozeilen-Argumente verarbeitet und ein paar andere vorbereitende Arbeiten abgehakt, um sodann die Main Event Loop zu erzeugen und zu starten.

Zeile 17: setup_shortcuts()

Die setup_shortcuts-Funktion registriert lediglich zwei Keyboard-Shortcuts für unsere noch zu erstellenden Menüeinträge: <Ctrl>o (also: Steuerung/Control drücken und halten, dann o drücken und loslassen, dann Steuerung/Control loslassen) für „win.open“ (Zeile 18) und <Ctrl>q für „window.close“ (Zeile 19). Die Bedeutungen dieser Funktionen erkläre ich weiter unten bei setup_actions().

Zeile 22: build_ui()

Wir instanziieren zunächst ein Objekt der Klasse gtk4::Builder und weisen es der Variablen builder zu (Zeile 23). Mit der Methode Builder::from_resource() sorgen wir dabei dafür, dass dieser Builder aus unserer zuvor angelegten Datei window.ui erzeugt wird. In Zeile 24 rufen wir die object()-Methode unseres Builders auf. Als Argument übergeben wir dabei „app_window“. Die Methode liefert uns daher das Objekt der Klasse gtk.ApplicationWindow mit der ID „app_window“ zurück, wie wir es in der Datei window.ui beschrieben haben. Allerdings liefert die Methode nicht direkt das Objekt selbst zurück, sondern es verpackt das Objekt in eine Option<T>. Mit expect() in Zeile 26 kontrollieren wir daher, ob der Aufruf uns wirklich ein Objekt zu zurück liefert. Sollte der Rückgabewert „None“ sein, bricht expect() das Programm mit einer entsprechenden Fehlermeldung ab. Man mag einwenden können, dass expect() (ebenso wie unwrap()) nicht die bestmögliche Art der Fehlerbehandlung ist. Wenn allerdings unser Anwendungsfenster nicht geladen werden kann, dürfte es wiederum kaum noch in sinnvoller Weise möglich sein, das Programm fortzusetzen. Wenn es expect() hingegen gelingt, unser ApplicationWindow wie gewünscht aus der Option<T> heraus zu schälen, wird es uns als glib::object::Object zurück gegeben. Deswegen geben wir als Typ der Variablen window ausdrücklich gtk4::ApplicationWindow an, damit wir sie dann auch problemlos als solches verwenden können.

In Zeile 27 rufen wir unsere Funktion setup_actions() auf, die unser Menü konfiguriert. Damit es mit unserem window verbunden werden kann, übergeben wir „leihweise“ eine Referenz auf unser Window. Zeile 28 fügt unser window, wiederum in Form einer Referenz verliehen, unserer gtk4::Application hinzu. In Zeile 29 schließlich rufen wir die present()-Methode unseres window auf. Die Methode macht genau das, was der Name sagt: Sie präsentiert das Fenster, was im Normalfall bedeutet, dass sie es sichtbar auf dem Bildschirm einblendet.

Zeile 31: setup_actions()

Die Funktion setup_actions() ist gewissermaßen die interessanteste, denn sie enthält die eigentliche Funktionalität, die nämlich mit der Action win.open verknüpft ist. Obwohl die Funktion setup_actions heißt, wird im Grunde nur eine Action, nämlich eben win.open, eingerichtet. Das mag irritieren, denn sowohl in der Datei window.ui wie auch in der Funktion setup_shortcuts() taucht ja jeweils mit window.close noch eine zweite Action auf. Eine Erklärung hierfür findet sich wiederum in dem entsprechenden Kapitel im Buch: window.close ist eine vordefinierte Action, so dass wir uns hierum kümmern müssen.

In Zeile 32 instanziieren wir zunächst ein neues Objekt der Klasse gio::SimpleAction und weisen es der Variablen action_open zu. Wenn eine Action ausgelöst wird, also zum Beispiel die entsprechende Tastenkombination (s.o.) gedrückt oder mit der Maus der entsprechende Menüeintrag angeklickt wird, wird das „activate“-Signal ausgelöst.

Mit connect_activate() verbinden wir jetzt in Zeile 33 die eigentliche Funktionalität unseres Programms mit diesem Signal. Diesmal verwenden wir dazu allerdings keine Funktion, sondern eine Closure. Dabei zwingen uns allerdings die rusteigenen Konzepte des Eigentums a und der Lebensdauer von Daten zu gewissen Verrenkungen. Die Problematik hat ein eigenes Kapitel im Buch. Im Kern geht es darum, dass nicht einfach ein Objekt bzw. eine Referenz auf ein Objekt übergeben kann, wie dies etwa in C gemacht wird, weil der Rust-Compiler prüfen möchte, ob die übergebenen Daten zum Zeitpunkt des Aufrufs des Callbacks noch gültig sind, was er aber nicht kann. Dem C-Compiler ist das einfach egal, er überlässt es gänzlich der*dem Programmierer*in, sich über solche Dinge Gedanken zu machen, ggf. mit fatalen Konsequenzen in Gestalt von segmentation faults, die einen sofortigen Abbruchs des Programms nach sich ziehen, wenn man dabei einen Fehler macht.

Die gute Nachricht ist, dass Rust mit Rc, Cell und RefCell auch die notwendigen Hilfsmittel mitbringt, die man braucht, um diese Probleme zu lösen. Die noch bessere Nachricht ist, dass wir diese Hilfsmittel gar nicht brauchen, weil Gtk mit glib::clone! ein Makro mitbringt, dass uns die Arbeit wesentlich einfacher macht. Daher reicht es, an den entsprechenden Stellen ein wenig Boilerplate-Code (hier: „(clone!(@weak window => move“) einzufügen, damit hat man es dann aber auch.

In Zeile 34 dann erzeugen wir unseren FileDialog wiederum mit dem Builder Battern und weisen ihn der Variablen file_dialog zu. Dabei rufen wir zu Demonstrationszwecken die Methode gtk4::builders::FileDialogBuilder::modal() mit dem Argument false auf. In einer produktiven Anwendung würde man sich wahrscheinlich in der Regel für einen modalen Dialog entscheiden, also für einen Dialog, der das Hauptfenster für weitere Eingaben sperrt.

In Zeile 35 öffnen wir das Dialogfenster mit dem Aufruf der Methode gtk4::FileDialog::open(). Wir geben dem FileDialog eine Referenz auf unser Window eingepackt in eine Option<T> mit. Man kann dem Dialog auch noch ein GCancellable mitgeben, den man nutzen kann, um die Operation noch abbrechen zu können. Da wir von dieser Möglichkeit hier keinen Gebrauch machen wollen, geben wir einfach nur None::<&gio::Cancellable> an. Als letztes Argument geben wir wiederum ein Callback als Closure an. Der Callback wird dann ausgeführt, sobald der FileDialog geschlossen wird.

Damit kommen wir zu dem Punkt, an dem ich die offizielle Dokumentation so verwirrend finde: Dort heißt es: „It should call Gtk::FileDialog::open_finish() to obtain the result.“ Tatsächlich hat die Klasse gtk4::FileDialog gar keine Methode open_finish(). Hier wird offenbar (automatisiert?) Text aus der Dokumentation aus dem C-Original übernommen, der aber in anderen Programmiersprachen nicht wirklich Sinn ergibt. Tatsächlich wird im Falle von FileDialog::open() das Ergebnis in Form eines Result <T, E> als Parameter an die Callback-Funktion übergeben. Dies ergibt sich für mich aber nicht aus der Doku. Ich bin erst darauf gekommen, nachdem ich diesen Thread auf discourse.gnome.org gefunden habe, in dem offenbar jemand anderes dasselbe Problem mit Vala hatte. Die dort beschriebene Lösung lässt sich zwar nicht direkt auf Rust übertragen, der springende Punkt ist aber eben, dass man dort sieht, dass der Zugriff auf das Ergebnis des Dialogs auf die für die jeweilige Programmiersprache typische Weise erfolgt.

In dem inneren Callback, in Zeile 36, deklarieren wir eine Variable vom Typ String, die wir nutzen können, um den Text zwischenspeichern zu können, den wir unserem Label später zuweisen wollen.

Ab Zeile 37 nutzen wir eine match expression, um das Result <T, E>, das unser FileDialog uns übergeben hat, auszuwerten. Wenn die*der Nutzer*in eine Datei ausgewählt hat, wird der Ok-Zweig des Match Expressions ausgeführt. Dabei wird der Variablen file ein gio::File zugewiesen. In Zeile 39 rufen wir die Methode gio::prelude::FileExt::path() für unser File auf, die einen std::path::PathBuf, wiederum verpackt in ein Option<T>, zurückliefert, den wir wiederum mit unwrap() auspacken. Den PathBuf weisen wir einer Variablen path zu.

In Zeile 40 übergeben wir eine Referenz auf path an open::that(). Diese Methode übergibt den Path an die Umgebung unseres Programms, wo diese Datei dann geöffnet wird. Wie genau das passiert, hängt von der jeweiligen Umgebung an. Genauere Infos hierzu finden sich im Repository des Crates open. In unserem kleinen Demo-Programm interessiert uns der Rückgabewert des Programms nicht weiter. Dies signalisieren wir dem Compiler, indem wir den Wert „_“ zuweisen. Anderenfalls erhielten wir beim Kompilieren eine Warnung, weil der Rückgabewert der Methode ignoriert wird.

In Zeile 41 basteln wir einen kurzen Text, den wir unserer Variablen text zuweisen. Sollte das vom FileDialog zurück gegebene Result<T, E> hingegen einen Err(E) enthalten, wird der Err-Zweig der Match-Expression ausgeführt. In diesem Falle basteln wir nur einen Text (Zeile 43). Erwähnenswert an dieser Stelle, dass insbesondere auch ein Err(e) übergehen wird, wenn die*der Anwender*in den FileDialog selbst abbricht, also beispielsweise die Escape-Taste drückt oder auch „Abbrechen“ klickt. Daher wird unser Label auch in diesem Falle eine Fehlermeldung anzeigen.

Dann schälen wir in den Zeilen 45 bis 48 unser gtk4::Label aus dem gtk4::Window heraus. Hierbei ist zunächst zu beachten, dass wir das nicht in derselben Weise erledigen können, in der wir in den Zeilen 23 bis 25 unser Fenster erzeugt haben. Zwar ließe sich entsprechender Code anstandslos kompilieren und es wäre auf den ersten Blick auch beim anschließenden Ausführen des Programms kein Fehler erkennbar. Tatsächlich allerdings würde der Builder dann eine neue Instanz eines Gtk4::Label erzeugen. Der Aufgrund von gtk4::Label::set_text() in Zeile 49 würde daher fehlschlagen und einfach gar keine erkennbaren Auswirkungen haben, da nur der Text des unsichtbaren neuen Labels überschrieben würde.

Immerhin haben wir eine Referenz auf unser Fenster. Mit der child()-Methode unseres Fensters bekommen wir wiederum eine Option<T>, die wir mit unwrap entpacken. Die child()-Methode weiß allerdings nicht, welcher Klasse das Objekt angehört, das sie zurückgibt, und gibt es uns deswegen als gtk4::Widget zurück. Hier hilft uns glib::Object::Cast::downcast(). Mit dieser Methode können wir das Objekt in ein gtk4::Label casten. Das funktioniert freilich nur so einfach, weil wir unsere window.zu kennen und wissen, dass unsere Fenster außerhalb der GtkHeaderbar keine weiteren „Kinder“ hat, als dieses Label. Zudem liefert auch die downcast()-Methode wiederum eine Option<T> zurück, sodass wir noch einmal unwrap() brauchen, um dann auch tatsächlich an unser Label zu kommen.

Zugegebenermaßen gibt es hier noch ein bisschen viel unwrap(), was nicht unbedingt als guter Stil gilt. In einer produktiven Anwendung würde man vielleicht noch etwas mehr Arbeit in die Fehlerbehandlung investieren.

In Zeile 49 rufen wir die Methode set_text() unseres Labels auf. Die Methode macht genau was, was ihr Name vermuten lässt: Der Text des Labels wird durch den übergebenen String ersetzt. Der zuvor enthaltene Text wird dabei also überschrieben.

In Zeile 52 schließlich fügen wir unsere Action noch unserem Fenster hinzu.

Die erste Fassung unseres Projekts ist damit fertig, und wir können das Programm starten, indem wir im Wurzelverzeichnis unseres Projekts

$ cargo run

eingeben.

Screenshot des laufenden Programms unter Linux/Gnome. Mehre FileDialoge sind gleichzeitig geöffnet. Eine Bilddatei wurde bereits geöffnet. Das Label wurde entsprechend geändert.

Man beachte, dass es problemlos möglich ist, mehrere Dialogfenster gleichzeitig zu öffnen, und dann in beliebiger Reihenfolge wieder zu schließen.

Jetzt noch mal in asynchron

Erst einmal müssen wir wieder ein Projekt und die für das UI benötigten Ressourcen anlegen; insoweit gelten meine obigen Ausführungen entsprechend, mit der Maßgabe, dass ich dieses Projekt jetzt „file_dialog_demo_async“ genannt habe und die entsprechenden Umbenennungen in den verschiedenen Dateien an den entsprechenden Stellen erforderlich sind (alternativ kannst du freilich auch einfach dein bestehendes Projekt ändern, das sei dir selbst überlassen).

Hier kommt die geänderte main.rs:

use open;
use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, Builder, FileDialog, Label, gio, glib};
use glib::clone;
use glib::MainContext;

const APP_ID: &str = "org.keienb.file_dialog_demo_async";

fn main() -> glib::ExitCode {
    gio::resources_register_include!("org_keienb_file_dialog_demo_async.gresource")
        .expect("Failed to register resources.");
    let app = Application::builder().application_id(APP_ID).build();
    app.connect_startup(setup_shortcuts);
    app.connect_activate(build_ui);
    app.run()
}

fn setup_shortcuts(app: &Application) {
    app.set_accels_for_action("win.open", &["<Ctrl>o"]);
    app.set_accels_for_action("window.close", &["<Ctrl>q"]);
}

fn build_ui(app: &Application) {
    let builder = Builder::from_resource("/org/keienb/file_dialog_demo_async/window.ui");
    let window :ApplicationWindow = builder.object("app_window")
        .expect("Failed to load application window from resource");
    setup_actions(&window);
    app.add_window(&window);
    window.present()
}

fn setup_actions(window: &ApplicationWindow) {
    let main_ctxt = MainContext::default();
    let action_open = gio::SimpleAction::new("open", None);
    action_open.connect_activate(clone!(@weak window => move |_, _| {
        main_ctxt.spawn_local(clone!(@weak window => async move {
            let file_dialog = FileDialog::builder().modal(false).build();
            let result = file_dialog.open_future(Some(&window)).await;
            let text :String;
            match result {
                Ok(file) => {
                    let path = file.path().unwrap();
                    let _ = open::that(&path);
                    text = format!("Opening: {path:#?}")
                }
                Err(e) => text = format!("Error: {e:#?}")
            };
            let label = window.child()
                .unwrap()
                .downcast::<Label>()
                .unwrap();
                label.set_text(&text);
        }));
    }));
    window.add_action(&action_open);
}

Die wesentlichen Unterschiede beschränken sich auf die Funktion setup_actions() (Zeile 32). Davor finden sich einige, wenige Unterschiede, die im Wesentlichen auf den leicht unterschiedlichen Namen zurückzuführen sind (Zeilen 7, 10 und 24). Außerdem müssen wir mit use glib::MainContext einbinden (Zeile 5).

Asynchrone Programmierung in Rust gilt als noch nicht richtig ausgereift. Zwar sind mit async und .await zwei Schlüsselwörter vorhanden, die den Grundstein für asynchrone Programmierung legen. Rust selbst und seine Standardbibliothek liefern jedoch keine asynchrone Laufzeitumgebung mit. Das ist in unserem Falle jedoch nicht weiter schlimm, weil GTK alles mitbringt, was wir brauchen. In Zeile 33 rufen wir mit glib::MainContext::default() den Kontext ab, in dem unsere asynchronen Funktionen ausgeführt werden sollen. Mit glib::MainContext::spawn_local() in Zeile 36 bewirken wir, dass, wenn der Callback für die Action „open“ aufgerufen wird, der entsprechende Code asynchron ausgeführt werden kann. Dazu markieren wir auch den nachfolgenden Code-Block mit dem Schlüsselwort async. Der Inhalt des Blocks entspricht überwiegend dem Inhalt der Funktion in der ersten Fassung, allerdings mit einem wesentichen Unterschied: Wir öffenen den Filedialog in Zeile 38 diesmal nicht mit FileDialog:open(), sondern mit FileDialog::open_future(). Dieser Funktion werden weder ein GCancellable noch ein Callback übergeben. open_future() liefert zwar auch ein Result<T, E> zurück, insoweit gilt das oben gesagte entsprechend. Es wird diesmal allerdings als Rückgabewert der Funktion geliefert, nicht als Argument an ein Callback übergeben. Auch dies geht aus der Dokumentation jedoch nicht wirklich hervor.

Wir weisen den Rückgabewert einer Variablen result zu. Freilich kann die Ausführung erst fortgesetzt werden, wenn der Rückgabewert auch tatsächlich vorliegt. Vorher hat result ja gar keinen oder zumindest keinen sinnvollen Inhalt. Deswegen nutzen wir an dieser Stelle .await, das wir hinter den Aufruf von open_future() setzen, und die Ausführung aussetzt, bis der Dialog geschlossen wird.

Wie man sieht, blähen die asynchronen Elemente unseren Code etwas auf. Führt man diese Fassung des Programms aus, fühlt man allerdings kaum einen Unterschied. Zumindest in trivialen Fällen wie diesen, wenn man nicht ohnehin aus anderen Gründen mit asynchronen Funktionen hantieren muss, muss der Mehrwert dieser Variante also bezweifelt werden. Vielfach wird GTK mit seinem eventgetriggerten Ansatz bereits asynchron genug sein.

Ein paar Anmerkungen zum Schluss

Getestet habe ich das so weit mit Gentoo Linux, macOS und Windows 11. Überall hat es funktioniert, und das ist aus meiner Sicht auch der wesentliche Vorteil von GTK. Man muss es nicht schön finden, aber das, was man bekommt, bekommt man halt auch auf allen Plattformen gleich. Leider fügt es sich dabei nicht unbedingt gut in die Umgebung, wie man beispielsweise unter macOS merkt, wo die Ampelbuttons links oben in der Ecke fehlen, sobald man GtkHeaderbar verwendet (ohne GtkHeaderbar sind sie vorhanden, aber dann wird es halt wieder komplizierter, das „Hamburger-Menü“ einzubinden). Auch stellt sich heraus, dass Klicks mit dem Mauszeiger auf die Einträge des Menüs unter macOS nicht funktionieren. Da dieses Problem allerdings nur auf dem Mac auftritt und sich die Einträge über die Tastatur problemlos auswählen und aktivieren lassen, vermute ich, dass da noch irgendein Bug in dieser GTK-Version dieses Problem verursacht.


Beitrag veröffentlicht

in

von

Schlagwörter:

Consent Management Platform von Real Cookie Banner