Python Desktop Handbook

Chapter 6 · GTK 4 with PyGObject

Custom Widgets

Every listing in this chapter is a file under examples/gtk4/custom-widgets/. They are run on each build, so if one of them stops working the build says so.

Introduction

The previous edition of this book had a chapter called Custom Widgets containing one line: “This chapter is not yet written :)”. This is that chapter.

There are three levels of “custom widget”, and most of the time the answer is the first one:

Compose. Subclass an existing container, build some children in __init__, and give it properties and signals so the rest of the program sees one thing.

Draw. Subclass Gtk.Widget and override do_snapshot() — covered in Drawing with Cairo, where the meter widget does exactly this.

Lay out. Subclass Gtk.Widget and override do_measure() and do_size_allocate() as well, so it can arrange children GTK has no container for.

Only the third is genuinely hard, and only because the sizing contract has a rule that is easy to miss. That rule is the most useful thing in this chapter.

Compose first

Before overriding anything, ask whether you are making a new kind of widget or a reusable arrangement of existing ones. It is nearly always the second.

class SearchBar(Gtk.Box):
    __gtype_name__ = "SearchBar"

    query = GObject.Property(type=str, default="")
    scope = GObject.Property(type=int, default=0)

    @GObject.Signal(arg_types=(str, int))
    def search_requested(self, query, scope):
        """Default handler; there is nothing for it to do."""

    def __init__(self, **kwargs):
        super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6, **kwargs)

        self._entry = Gtk.SearchEntry(hexpand=True, placeholder_text="Search…")
        self._scope = Gtk.DropDown.new_from_strings(SCOPES)
        self._button = Gtk.Button(label="Search")

        self.append(self._entry)
        self.append(self._scope)
        self.append(self._button)

What makes this a widget rather than a helper function is the public surface — the properties and the signal from GObject. From outside, nothing needs to know there is an entry and a drop down inside:

search.connect("search-requested", lambda _b, query, scope: ...)
search.bind_property("query", label, "label", GObject.BindingFlags.SYNC_CREATE)

Inside, connect the children to the properties with bindings rather than handlers, so the widget and its state cannot drift apart:

self._entry.bind_property("text", self, "query",
                          GObject.BindingFlags.BIDIRECTIONAL
                          | GObject.BindingFlags.SYNC_CREATE)

Two details make a composed widget feel like a real one. Prefix the children with an underscore — they are implementation, and something will eventually reach in and grab them if you do not. And override grab_focus() to forward to whichever child should actually get the focus:

def grab_focus(self):
    return self._entry.grab_focus()

For anything with more than a handful of children, put the layout in a .ui file and use Gtk.Template, as in More GTK 4. The class is the same shape; only where the children come from changes.

The full example is examples/gtk4/custom-widgets/composed-widget.py.

A composed widget: one entry, one drop down and one button, presented as a single thing

A widget that lays out children

When no existing container does what you need — and GTK has a lot of them, so check first — you subclass Gtk.Widget and implement three methods.

GTK 4 has no GtkContainer. There is no add() to override and no child list to maintain. A child is parented to you, and the widget itself keeps the list:

def append(self, child):
    child.set_parent(self)

def children(self):
    child = self.get_first_child()
    while child is not None:
        yield child
        child = child.get_next_sibling()

Unparent your children, or GTK complains

def do_dispose(self):
    child = self.get_first_child()
    while child is not None:
        next_child = child.get_next_sibling()
        child.unparent()
        child = next_child
    Gtk.Widget.do_dispose(self)

Leave this out and you get, at some later and unrelated moment:

Finalizing GtkWidget, but it still has children left

Take the next sibling before unparenting, because unparenting is what detaches it from the list you are walking.

Measuring

def do_measure(self, orientation, for_size):
    return minimum, natural, min_baseline, nat_baseline

GTK asks how big you would like to be, then gives you a size that may be different, then asks you to draw. The contract is that do_measure must not lie: a widget asking for less than it needs gets clipped, and one asking for more leaves holes.

Return -1, -1 for the baselines unless you are aligning text across widgets.

The rule that catches everyone

If your height depends on your width — a wrapping layout, a text flow, anything that reflows — you must say so:

def do_get_request_mode(self):
    return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH

The default is CONSTANT_SIZE, which tells GTK that height does not depend on width. GTK then never passes a real width to do_measure(): for_size is always -1, your widget measures itself as one row tall however narrow it gets, and the rows below the first are silently clipped.

Nothing warns. The widget looks correct at its natural width and wrong at every other width, which is exactly the case you are least likely to test first.

It is worth seeing the difference. The wrapping example below, laid out at 300 pixels wide, genuinely occupies three rows:

row y=0:  ['alpha', 'bravo', 'charlie']
row y=42: ['delta', 'echo', 'foxtrot', 'golf']
row y=84: ['hotel']

With the default request mode, measure(VERTICAL, 300) reports 34 — one row. With HEIGHT_FOR_WIDTH it reports 118, which is what the layout actually needs.

Allocating

def do_size_allocate(self, width, height, baseline):
    for child in self.children():
        transform = Gsk.Transform().translate(Graphene.Point().init(x, y))
        child.allocate(child_width, child_height, -1, transform)

A child is positioned by a transform, not by an x/y pair — which is what makes it possible to rotate or scale a child, and why the drawing chapter’s snapshot transforms compose with layout.

Ask each child how big it wants to be rather than assuming:

child_width = child.measure(Gtk.Orientation.HORIZONTAL, -1)[1]
child_height = child.measure(Gtk.Orientation.VERTICAL, child_width)[1]

Note the second call passes the width — that is how you get a correct height from a child that is itself height-for-width.

Write the walk once and use it for both. The example’s _layout() places children when asked to allocate and only totals up the height when asked to measure:

def _layout(self, visible, width, allocate=False):
    ...
    if allocate:
        child.allocate(child_width, child_height, -1, transform)
    ...
    return total

Measuring and allocating with two separate pieces of code is the classic way to get a container that is subtly the wrong size, because the two drift apart the first time one of them is edited.

Drawing

A container usually only has to draw its children, and the default implementation already does:

def do_snapshot(self, snapshot):
    Gtk.Widget.do_snapshot(self, snapshot)

Override it properly only when you want to draw around the children — a background, a focus ring, separators between rows.

The full example is examples/gtk4/custom-widgets/flow-box-widget.py.

Children wrapped onto three rows by a custom container

Layout managers

There is a fourth option worth knowing. Instead of putting the layout logic in the widget, put it in a Gtk.LayoutManager:

class WrapLayout(Gtk.LayoutManager):
    __gtype_name__ = "WrapLayout"

    def do_get_request_mode(self, widget):
        return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH

    def do_measure(self, widget, orientation, for_size): ...
    def do_allocate(self, widget, width, height, baseline): ...


widget.set_layout_manager(WrapLayout())

The methods are the same, with the widget passed in. The advantage is that the layout can be swapped at runtime and reused on any widget — which is how Gtk.BoxLayout, Gtk.GridLayout and Gtk.BinLayout are implemented, and why Gtk.Box is a thin wrapper around one.

Use a layout manager if the arrangement is reusable; put it in the widget if it is specific to that widget.

Styling and accessibility

Give the widget its own CSS name so it can be styled without depending on the class name:

class WrapBox(Gtk.Widget):
    __gtype_name__ = "WrapBox"

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.set_css_name("wrapbox")
wrapbox { padding: 6px; }

And tell the accessibility layer what it is, because a Gtk.Widget subclass defaults to a role that means nothing to a screen reader:

class Meter(Gtk.Widget):
    __gtype_name__ = "Meter"

    def __init__(self, **kwargs):
        super().__init__(accessible_role=Gtk.AccessibleRole.PROGRESS_BAR, **kwargs)
        self.update_property([Gtk.AccessibleProperty.VALUE_NOW], [0.35])

A composed widget usually needs nothing here — its children already carry their own roles — which is one more argument for composing.

Summary

  • Compose before you subclass Gtk.Widget. Properties and signals are what make a composed widget a widget; use bindings inside it and forward grab_focus().
  • There is no GtkContainer. Children are parented, and walked with get_first_child() / get_next_sibling().
  • Unparent every child in do_dispose(), taking the next sibling first.
  • If height depends on width, override do_get_request_mode(). Without it for_size is always -1, the widget measures one row tall, and the rest is clipped with no warning.
  • Children are placed with a Gsk.Transform, not an x/y pair.
  • Measure children with child.measure(), passing the width when asking for a height.
  • Use one code path for measuring and allocating.
  • Put reusable arrangements in a Gtk.LayoutManager.
  • Set a CSS name, and set an accessible role on anything that draws itself.

Printing is next.