I see a lot of users approaching GNOME app development with prior language-specific experience, be it Python, Rust, or something else. But there’s another way to approach it: GObject
-oriented and UI first.
This introduces more declarative code, which is generally considered cleaner and easier to parse. Since this approach is inherent to GTK
, it can also be applied in every language binding. The examples in this post stick to Python and Blueprint.
Properties
While normal class properties for data work fine, using GObject
properties allows developers to do more in UI through expressions.
Handling Properties Conventionally
Let’s look at a simple example: there’s a progress bar that needs to be updated. The conventional way of doing this would look something like the following:
using Gtk 4.0; using Adw 1; template $ExampleProgressBar: Adw.Bin { ProgressBar progress_bar {} }
This defines a template called ExampleProgressBar
which extends Adw.Bin
and contains a Gtk.ProgressBar
called progress_bar
.
The reason why it extends Adw.Bin
instead of Gtk.ProgressBar
directly is because Gtk.ProgressBar
is a final class, and final classes can’t be extended.
from gi.repository import Adw, GLib, Gtk @Gtk.Template(resource_path="/org/example/App/progress-bar.ui") class ExampleProgressBar(Adw.Bin): __gtype_name__ = "ExampleProgressBar" progress_bar: Gtk.ProgressBar = Gtk.Template.Child() progress = 0.0 def __init__() -> None: super().__init__() self.load() def load(self) -> None: self.progress += 0.1 self.progress_bar.set_fraction(self.progress) if int(self.progress) == 1: return GLib.timeout_add(200, self.load)
This code references the earlier defined progress_bar
and defines a float called progress
. When initialized, it runs the load
method which fakes a loading operation by recursively incrementing progress
and setting the fraction of progress_bar
. It returns once progress
is 1.
This code is messy, as it splits up the operation into managing data and updating the UI to reflect it. It also requires a reference to progress_bar
to set the fraction
property using its setter method.
Handling Properties With GObject
Now, let’s look at an example of this utilizing a GObject
property:
using Gtk 4.0; using Adw 1; template $ExampleProgressBar: Adw.Bin { ProgressBar { fraction: bind template.progress; } }
Here, the progress_bar
name was removed since it isn’t needed anymore. fraction
is bound to the template’s (ExampleProgressBar
‘s) progress
property, meaning its value is synced.
from gi.repository import Adw, GLib, GObject, Gtk @Gtk.Template(resource_path="/org/example/App/progress-bar.ui") class ExampleProgressBar(Adw.Bin): __gtype_name__ = "ExampleProgressBar" progress = GObject.Property(type=float) def __init__() -> None: super().__init__() self.load() def load(self) -> None: self.progress += 0.1 if int(self.progress) == 1: return GLib.timeout_add(200, self.load)
The reference to progress_bar
was removed in the code too, and progress
was turned into a GObject
property instead. fraction
doesn’t have to be manually updated anymore either.
So now, managing the data and updating the UI merged into a single property through a binding, and part of the logic was put into a declarative UI file.
In a small example like this, it doesn’t matter too much which approach is used. But in a larger app, using GObject
properties scales a lot better than having widget setters all over the place.
Communication
Properties are extremely useful on a class level, but once an app grows, there’s going to be state and data communication across classes. This is where GObject
signals come in handy.
Handling Communication Conventionally
Let’s expand the previous example a bit. When the loading operation is finished, a new page has to appear. This can be done with a callback, a method that is designed to be called by another method, like so:
using Gtk 4.0; using Adw 1; template $ExampleNavigationView: Adw.Bin { Adw.NavigationView navigation_view { Adw.NavigationPage { child: $ExampleProgressBar progress_bar {}; } Adw.NavigationPage { tag: "finished"; child: Box {}; } } }
There’s now a template for ExampleNavigationView
, which extends an Adw.Bin
for the same reason as earlier, which holds an Adw.NavigationView
with two Adw.NavigationPage
s.
The first page has ExampleProgressBar
as its child, the other one holds a placeholder and has the tag “finished”. This tag allows for pushing the page without referencing the Adw.NavigationPage
in the code.
from gi.repository import Adw, Gtk from example.progress_bar import ExampleProgressBar @Gtk.Template(resource_path="/org/example/App/navigation-view.ui") class ExampleNavigationView(Adw.Bin): __gtype_name__ = "ExampleNavigationView" navigation_view: Adw.NavigationView = Gtk.Template.Child() progress_bar: ExampleProgressBar = Gtk.Template.Child() def __init__(self) -> None: super().__init__() def on_load_finished() -> None: self.navigation_view.push_by_tag("finished") self.progress_bar.load(on_load_finished)
The code references both navigation_view
and progress_bar
. When initialized, it runs the load
method of progress_bar
with a callback as an argument.
This callback pushes the Adw.NavigationPage
with the tag “finished” onto the screen.
from typing import Callable from gi.repository import Adw, GLib, GObject, Gtk @Gtk.Template(resource_path="/org/example/App/progress-bar.ui") class ExampleProgressBar(Adw.Bin): __gtype_name__ = "ExampleProgressBar" progress = GObject.Property(type=float) def load(self, callback: Callable) -> None: self.progress += 0.1 if int(self.creation_progress) == 1: callback() return GLib.timeout_add(200, self.load, callback)
ExampleProgressBar
doesn’t run load
itself anymore when initialized. The method also got an extra argument, which is the callback we passed in earlier. This callback gets run when the loading has finished.
This is pretty ugly, because the parent class has to run the operation now.
Another way to approach this is using a
Gio.Action
. However, this makes illustrating the point a bit more difficult, which is why a callback is used instead.
Handling Communication With GObject
With a GObject
signal the logic can be reversed, so that the child class can communicate when it’s finished to the parent class:
using Gtk 4.0; using Adw 1; template $ExampleNavigationView: Adw.Bin { Adw.NavigationView navigation_view { Adw.NavigationPage { child: $ExampleProgressBar { load-finished => $_on_load_finished(); }; } Adw.NavigationPage { tag: "finished"; child: Box {}; } } }
Here, we removed the name of progress_bar
once again since we won’t need to access it anymore. It also has a signal called load-finished
, which runs a callback called _on_load_finished
.
from gi.repository import Adw, Gtk from example.progress_bar import ExampleProgressBar @Gtk.Template(resource_path="/org/example/App/navigation-view.ui") class ExampleNavigationView(Adw.Bin): __gtype_name__ = "ExampleNavigationView" navigation_view: Adw.NavigationView = Gtk.Template.Child() @Gtk.Template.Callback() def _on_load_finished(self, _obj: ExampleProgressBar) -> None: self.navigation_view.push_by_tag("finished")
In the code for ExampleNavigationView
, the reference to progress_bar
was removed, and a template callback was added, which gets the unused object argument. It runs the same navigation action as before.
from gi.repository import Adw, GLib, GObject, Gtk @Gtk.Template(resource_path="/org/example/App/progress-bar.ui") class ExampleProgressBar(Adw.Bin): __gtype_name__ = "ExampleProgressBar" progress = GObject.Property(type=float) load_finished = GObject.Signal() def __init__(self) -> None: super().__init__() self.load() def load(self) -> None: self.progress += 0.1 if int(self.creation_progress) == 1: self.emit("load-finished") return GLib.timeout_add(200, self.load)
In the code for ExampleProgressBar
, a signal was added which is emitted when the loading is finished. The responsibility of starting the load operation can be moved back to this class too. The underscore and dash are interchangeable in the signal name in PyGObject.
So now, the child class communicates to the parent class that the operation is complete, and part of the logic is moved to a declarative UI file. This means that different parent classes can run different operations, while not having to worry about the child class at all.
Next Steps
Refine is a great example of an app experimenting with this development approach, so give that a look!
I would also recommend looking into closures, since it catches some cases where an operation needs to be performed on a property before using it in a binding.
Learning about passing data from one class to the other through a shared object with a signal would also be extremely useful, it comes in handy in a lot of scenarios.
And finally, experiment a lot, that’s the best way to learn after all.
Thanks to TheEvilSkeleton for refining the article, and Zoey for proofreading it.
Happy hacking!