Status report on my port of Setzer to libwaita!
Last time we left off with the Recently Opened Documents popover, also internally known as DocumentChooser. I’d say that it is “usable” now, although some improvements can be made upon this implementation of mine. It’s online and can be found here.
This version of DocumentChooser is mostly borrowed from GNOME Text Editor’s OpenPopover widget. A key difference is that this one utilizes GListView with the navigation-sidebar style, whereas Text Editor used GListBox. GListView is a new addition to GTK4. Unless we need the boxed-list style exclusively from GListBox (in this case we don’t), GListView should be more optimal (since it is a recyclable list widget whereas GListBox isn’t) and it has the navigation-sidebar style which fits this situation better, so less style gymnastics.
I’ll take this opportunity to explain the process, how I get this version of the popover (and other versions of other widgets) implemented!
The Process
Design
First, I must put widgets together in order to create one composite DocumentChooser widget. But not code directly into Setzer’s code base, because building a whole project takes time + integration issues. I must develop the widget first in a separate environment. To that end, I used Workbench.
One slight issue is that PyGObject is not available in Workbench, as of the time of this writing. So I write the widget first in JavaScript then translate to Python.
A twist in prototyping widgets for Setzer is that I don’t use an interface description file to lay out the widgets. Basically, in GTK you can design a widget using XML or Blueprint which saves a lot of time. But Setzer codebase uses neither. For the translation process to be as direct as possible, I don’t design using interface description during prototyping. This means a lot of typing - everything must be done by application code.
Gtk.Box {
halign: fill;
hexpand: true;
orientation: vertical;
Gtk.Label {
label: 'test.txt';
halign: start;
ellipsize: end;
styles ["file_name"]
}
Gtk.Label {
label: '~/Projects/test.txt';
halign: start;
ellipsize: end;
styles ['dim-label', 'file_path']
}
}
Markup code (Blueprint)
const vbox_left = new Gtk.Box();
vbox_left.set_halign(Gtk.Align.FILL);
vbox_left.set_hexpand(true);
vbox_left.set_orientation(Gtk.Orientation.VERTICAL);
const file_name = new Gtk.Label();
file_name.set_label("test.txt");
file_name.set_halign(Gtk.Align.START);
file_name.set_ellipsize(Pango.EllipsizeMode.END);
file_name.add_css_class("file_name");
vbox_left.append(file_name);
file_path = new Gtk.Label();
file_path.set_label("~/Projects/test.txt");
file_path.set_halign(Gtk.Align.START);
file_path.set_ellipsize(Pango.EllipsizeMode.END);
file_path.add_css_class("dim-label");
file_path.add_css_class("file_path");
vbox_left.append(file_path);
Application Code (JavaScript)
Functionality
So, I design the widget like that. Then I add functionalities to the widget. Make buttons work, make elements use data from sources.
Interface consistency
One of the challenges when developing in a project that you don’t own is that you must adapt to how the system is already working. The original DocumentChooser widget provides a very specific interface for other components to use. And it was set up in a very specific manner. So we should uphold the same interface/procedure.
For example, the presenter code includes a reference view
to the widget we’re developing. Here, we expect to be able to connect signals with view.connect
, to access the search bar widget with view.search_bar
, to access the button that opens file dialog with view.other_documents_button
. Also, a base GtkPopover interface, since the original widget subclasses GtkPopover. And more.
class DocumentChooserPresenter(object):
def __init__(self, workspace):
# ...
# get the DocumentChooser widget
self.view = ServiceLocator.get_main_window().headerbar.document_chooser
# `view` implements GObject
self.view.connect('closed', self.on_document_chooser_closed)
# `view` export its children widget `search_entry` and `other_documents_button`
self.view.search_entry.connect('search-changed', self.on_document_chooser_search_changed)
self.view.other_documents_button.connect('clicked', self.on_other_docs_clicked)
def on_stop_search(self, search_entry):
# `view` also implements GtkPopover
self.view.popdown()
The original implementation of view
is something like this.
class DocumentChooser(Gtk.Popover):
def __init__(self):
# ...
self.search_entry = Gtk.SearchEntry()
self.other_documents_button = Gtk.Button.new_with_label(_('Other Documents') + '...')
Our new (mock-up) implementation will look and act very differently while trying to maintain this interface.
class OtherDocumentsWrapper extends Gtk.Stack {
constructor() {
// ...
this.states = [];
const button_state_available = new Gtk.Button;
this.states.push(button_state_available);
// ...
}
// new method
set_state(state) {
switch (state) {
case 'state_available':
// ...
case 'unknown':
// ...
}
}
// adapt to the GtkButton interface
connect(signal, handler) {
if (signal === 'clicked') {
for (const button of this.states) {
button.connect(signal, handler);
}
} else {
// ...
}
}
}
class DocumentChooser extends GObject.Object {
constructor() {
// ...
this.search_entry = new Gtk.SearchEntry;
this.other_documents_button = new OtherDocumentsWrapper;
this.widget = new Gtk.Popover;
}
popdown() {
this.widget.popdown();
}
}
So, the interfaces of view
, view.search
, and view.other_documents_button
are the same, but their internals are completely different.
For example, according to how presenter code uses view
, view
should have the GtkPopover interface. Our new implementation tries to satisfy this requirement, but in a different way compared to how the original implemnetation does it. Originally, DocumentChooser inherits GtkPopover. Our new implemenation does not inherit but only mimick GtkPopover aka we extend via composition instead of inheritance.
Similarly, in our new implementation other_documents_button
is not a GtkButton at all, but a GtkStack. I only faked a GtkButton interface.
But why going through all of this trouble? While it is not required that the new code is the same as the old code, much resemblance is appreciated. This will mean less refactoring when the fork is merged into origin. Even when the differences don’t cause merge conflicts, it still helps code readers.
Integration
Finally I implement the widget in Setzer using the prototype as reference. Because Python and JavaScript are “higher-level languages”, and we’ve maintained a relatively similar interface for the new component, so the translation is mostly 1-to-1. Some changes I must make are:
- Hashmap. Objects in JavaScript are maps by default, whereas in Python I must use dictionary. It’s not as convenient to use dictionary in Python as to use objects in JavaScript. When possible, prefer tuple over dictionary.
- Inline closures. More of a characteristic of Setzer’s codebase. Functions should not be defined inline. The number of parameters also matter. Python will throw exception if function signatures do not match.
class DocumentChooserPresenter extends GObject.Object {
constructor() {
this.view.factory.connect("bind", (obj, listitem) => {
// ...
});
this.view.search_entry.connect("changed", (obj) => {
// ...
});
}
}
Example JavaScript signal connection code. Arrow functions really helped here.
class DocumentChooser(GObject.Object):
def __init__(self, workspace):
# ...
self.view.factory.connect("bind", self.on_popover_listview_factory_bind)
self.view.search_entry.connect('changed', self.on_search_entry_changed)
def on_popover_listview_factory_bind(self, object, listitem):
# ...
def on_search_entry_changed(self, object):
# ...
Example Python signal connection code. Handler code isn’t written inline. Technically, inline functions can be defined, but they are not lambdas so they are not anonymous, must be named, very cumbersome.
Testing
Nah. At least, the implementation is quite straight-forward; nothing complicated is running behind the scenes.
Product
Recently Opened Documents popover - before
Walkthrough of the new iteration of the Recently Opened Documents popover
Reservations
I used GSignal instead of Setzer’s Observable as the signal system for this widget. I’m more familiar with GSignal, but I feel like I’ve could have taken some risks so that this widget can fit in better.
The style isn’t the same. Text Editor uses GListBox while this implementation uses GListView. The latter should be superior in terms of performance because it’s a recyclable list, but we don’t get the same exact same visual details. The hover effect is slightly off, for example.
Other Updates
I’ve finished working on DocumentChooser for a few weeks now. I’ve been working on other widgets, but progress has been slow due to work at university.
Close Confirmation Dialog - before
Close Confirmation Dialog - after
When I was working on it I found a pretty critical bug, apparently the saving functionality for this dialog has not been working at all. Guess no one noticed because most users would cancel the dialog when they realize there are unsaved changes. Anyway, I filed a PR and it was merged.
Context Menu - before and after
Zoom Menu - before and after
Shortcuts Bar Menus - before and after. The bar itself is another can of worm.
The Future
Next time will be OpenDocsPopover. It’s a popover where you manage tabs aka current session.
Current iteration of the OpenDocsPopover widget
I’ve been working on it on and off for quite some time and it has been hard. Main issue is that Setzer has its own popover builder tool, one which I had to remake/extend. Original popovers use custom widgets, custom stylesheets and such likes, so with any visual updates to the GTK framework the visuals all break down. This version of the popover builder will be a wrapper over GTK’s GioMenu API, no more custom solution. Only problem is that this requires some interface gymnastics - a lot.
Shortcutbar
A menubar with lots of commands and menus is not very GTK. While it isn’t relatively easy porting popovers to use GMenu, changing the bar itself isn’t. Changing the bar will involve a whole redesign.
Wizards
A lot of layout patterns seem to have been borrowed from QT. Also won’t be easy to overhaul. These include the New Document window, the Add/Remove package window, the BibTex window, etc.
And there are many other subjects of enhancement, but I’ll leave them for the next post.
Unrelated thoughts
The overarching theme has been to adapt to the conventions set forth by the current code base. However, I’ve found myself in situations where it’s better to just remake things.
What I’ve realized is that it’s bold to assume that the current code is working well. Sure, it works today, on your setup. But what about tomorrow, what about on my machine? Setzer was created 4 years ago in GTK3. Now we use GTK4. My DE setup has libadwaita, and it has a tweak where all texts are enlarged. Most apps held up to these modifications, but not Setzer. Setzer seems to struggle a lot. That’s why it’s important to reinvent the wheel sometimes.
Thanks for reading this blog post! This fork is my attempt to give a libadwaita coat of paint to Setzer, you can check it out here!
Cheers. :-)