BlinkGTK Event Handling Tutorial (Python/PyGObject)

Last Updated: 2026-10-21
Target Version: BlinkGTK 1.2.5 or later
Level: Beginner to Intermediate
Language: 日本語 | English


Table of Contents

  1. Introduction
  2. Signal Basics
  3. The Four Signals
  4. Pythonic Patterns
  5. Waiting for a Load to Finish
  6. Complete Browser Implementation

Introduction

This tutorial shows how to handle events in Python with BlinkGTK signals.
Every complete program here was run and checked with BlinkGTK 1.2.5 (Chromium 156).

Requirements

If you used code from an earlier version of this tutorial

The code previously on this page did not work, because it used forms that differ from the real API, such as import blink_gtk, app.run() and a title-changed handler with an argument.
Replace it with the code on this page.

What You Will Learn


Signal Basics

Connecting Signals

The following program prints the four load-related signals to the console.
Run it as blinkgtk-run python3 signals.py https://example.com/ (why blinkgtk-run is needed).

#!/usr/bin/env python3
"""Print the four load-related signals to the console"""
import sys

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("BlinkGTK", "0.1")
from gi.repository import BlinkGTK, Gtk  # noqa: E402

EVENT_NAMES = {
    BlinkGTK.LoadEvent.STARTED: "started",
    BlinkGTK.LoadEvent.COMMITTED: "committed",
    BlinkGTK.LoadEvent.FINISHED: "finished",
}


def on_load_changed(view, event):
    print(f"[load-changed] {EVENT_NAMES.get(event, event)}: {view.get_uri()}", flush=True)


def on_load_failed(view, error_code, failing_uri):
    print(f"[load-failed] error code {error_code}: {failing_uri}", flush=True)


def on_title_changed(view):
    print(f"[title-changed] {view.get_title()}", flush=True)


def on_uri_changed(view):
    print(f"[uri-changed] {view.get_uri()}", flush=True)


def on_activate(app, url):
    window = Gtk.ApplicationWindow(application=app, title="Signal Example")
    window.set_default_size(800, 600)

    view = BlinkGTK.WebView.new()
    view.connect("load-changed", on_load_changed)
    view.connect("load-failed", on_load_failed)
    view.connect("title-changed", on_title_changed)
    view.connect("uri-changed", on_uri_changed)
    window.set_child(view)
    window.present()

    view.load_uri(url)


def main():
    url = sys.argv[1] if len(sys.argv) > 1 else "https://example.com/"
    ok, _argv = BlinkGTK.gtk_init(sys.argv[:1])
    if not ok:
        print("Could not initialize BlinkGTK", file=sys.stderr)
        return 1
    app = Gtk.Application(application_id="org.example.SignalExample")
    app.connect("activate", on_activate, url)
    rc = BlinkGTK.gtk_application_run(app)
    BlinkGTK.gtk_shutdown()
    return rc


if __name__ == "__main__":
    sys.exit(main())

Opening https://example.com/ produces the signals in this order:

[uri-changed] https://example.com/
[load-changed] started: https://example.com/
[load-changed] committed: https://example.com/
[title-changed] Example Domain
[load-changed] finished: https://example.com/

For some pages, uri-changed arrives once more when the document is committed (for example, when opening a local file).
Do not write code that depends on how many times uri-changed arrives.

Startup Sequence

Every program uses BlinkGTK in this order:

  1. BlinkGTK.gtk_init(sys.argv[:1]) — once, before creating any widget
  2. BlinkGTK.gtk_application_run(app) — do not use app.run(). Returns when the last window is closed
  3. BlinkGTK.gtk_shutdown() — call it at the end

Short Handlers with lambda

# Simple logging (value_nick is a name such as "started")
web_view.connect("load-changed",
                 lambda w, e: print(f"Load event: {e.value_nick}"))

# Updating the title (title-changed has no argument, so read get_title())
web_view.connect("title-changed",
                 lambda w: window.set_title(f"{w.get_title()} - Browser"))

The Four Signals

1. load-changed

The handler receives (web_view, load_event). load_event is a BlinkGTK.LoadEvent value.

def on_load_changed(self, web_view, load_event):
    """The load state changed (used for status text)"""
    if load_event == BlinkGTK.LoadEvent.COMMITTED:
        self.status_label.set_text("Document committed")
    elif load_event == BlinkGTK.LoadEvent.FINISHED:
        self.status_label.set_text("Done")

2. load-failed

The handler receives (web_view, error_code, failing_uri).

def on_load_failed(self, web_view, error_code, failing_uri):
    """A load ended without committing"""
    # Ignore failures that are not for the page on screen (such as iframes) before touching the UI
    if failing_uri != (web_view.get_uri() or failing_uri):
        return
    if error_code == -3:
        # Aborted. Happens normally with the stop button or when another page is opened during a load
        self.status_label.set_text("Aborted")
        return
    self.status_label.set_text(f"Could not load (error code {error_code}): {failing_uri}")

3. title-changed

The handler receives only (web_view). Read the new title with get_title().

def on_title_changed(self, web_view):
    """The title changed"""
    title = web_view.get_title()
    self.set_title(f"{title} - Python Browser" if title else "Python Browser")

4. uri-changed

The handler receives only (web_view). Read the new URI with get_uri().

def on_uri_changed(self, web_view):
    """The URI changed"""
    uri = web_view.get_uri() or ""
    self.url_entry.set_text(uri)

    # Show the connection type
    if uri.startswith("https://"):
        self.security_label.set_text("HTTPS")
    elif uri.startswith("http://"):
        self.security_label.set_text("HTTP")
    else:
        self.security_label.set_text("")

Whether a Load Is in Progress (notify::is-loading)

Changes to the is-loading property arrive through notify::is-loading. The handler receives (web_view, pspec); read the value with web_view.is_loading().
It arrives when a load starts and when it ends, for every kind of navigation, including links, back, forward and reload.
However a load ends (finished, failed or aborted), the value returns to False.

def on_is_loading_changed(self, web_view, _pspec):
    """Switch the loading indicator and the stop and reload buttons"""
    loading = web_view.is_loading()
    self.progress_bar.set_visible(loading)
    if loading:
        self.progress_bar.pulse()
    self.stop_button.set_sensitive(loading)
    self.reload_button.set_sensitive(not loading)
    self.back_button.set_sensitive(web_view.can_go_back())
    self.forward_button.set_sensitive(web_view.can_go_forward())


web_view.connect("notify::is-loading", self.on_is_loading_changed)

It may arrive twice in a row at the start, so decide by the value, not by the number of notifications.

Other Property Change Notifications

title and uri are also properties, so you can receive changes through notify::title and notify::uri.

web_view.connect("notify::title", lambda w, pspec: print(f"title = {w.props.title}"))

Pythonic Patterns

1. Signal Handlers with a Decorator

When an exception is raised in a GObject signal handler, the program does not stop; the exception is only printed.
To make it clear which signal the exception came from, wrap the handler with a decorator.

import sys
from functools import wraps
from typing import Callable


def signal_handler(signal_name: str) -> Callable:
    """Print exceptions raised in the handler, with the signal name"""
    def decorator(func: Callable) -> Callable:
        @wraps(func)
        def wrapper(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except Exception as e:
                print(f"Exception in {signal_name} handler: {e!r}", file=sys.stderr)
        return wrapper
    return decorator


class BrowserWindow(Gtk.ApplicationWindow):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.web_view = BlinkGTK.WebView.new()
        self.set_child(self.web_view)
        self.web_view.connect("load-changed", self.on_load_changed)
        self.web_view.connect("load-failed", self.on_load_failed)

    @signal_handler("load-changed")
    def on_load_changed(self, web_view, load_event):
        if load_event == BlinkGTK.LoadEvent.FINISHED:
            print("Load finished")

    @signal_handler("load-failed")
    def on_load_failed(self, web_view, error_code, failing_uri):
        print(f"Ended without committing: {failing_uri} (error code {error_code})")

2. A Signal Manager Class

Keeping the IDs returned by connect() lets you remove the handlers later with disconnect().

from typing import Callable, Dict, List


class SignalManager:
    """Remember the handlers connected to a WebView, per signal name"""

    def __init__(self, web_view: BlinkGTK.WebView):
        self.web_view = web_view
        self.handlers: Dict[str, List[int]] = {}

    def connect(self, signal_name: str, handler: Callable) -> int:
        handler_id = self.web_view.connect(signal_name, handler)
        self.handlers.setdefault(signal_name, []).append(handler_id)
        return handler_id

    def disconnect(self, signal_name: str) -> None:
        for handler_id in self.handlers.pop(signal_name, []):
            self.web_view.disconnect(handler_id)

    def disconnect_all(self) -> None:
        for signal_name in list(self.handlers):
            self.disconnect(signal_name)


# Usage
signal_manager = SignalManager(web_view)
signal_manager.connect("load-changed", on_load_changed)
signal_manager.connect("title-changed", on_title_changed)

# Remove them later
signal_manager.disconnect_all()

Waiting for a Load to Finish

BlinkGTK.gtk_application_run() runs the GLib main loop.
An asyncio event loop started with asyncio.run() or similar does not run during that time, so waiting on an asyncio.Event for a load to finish does not work as is.

To do the next step after a load finishes, start it when notify::is-loading reports that is-loading has returned to False.
If the load fails, load-failed arrives before the value returns to False, so remember what it reported.
The following program loads the URLs given as arguments one after another and stops any load that does not finish within 30 seconds.
When all are done it closes the window and the program exits.

#!/usr/bin/env python3
"""Load several URLs one after another, then close the window"""
import sys

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("BlinkGTK", "0.1")
from gi.repository import BlinkGTK, GLib, Gtk  # noqa: E402

TIMEOUT_SECONDS = 30


class SequentialLoader:
    def __init__(self, window, view, urls):
        self.window = window
        self.view = view
        self.urls = list(urls)
        self.current = None
        self.timeout_id = 0
        self.started = False
        self.error_code = None
        self.timed_out = False
        # Detect the end of a load with notify::is-loading. However the load ends (finished, failed
        # or aborted), is-loading returns to False. A failure sends load-failed before that
        view.connect("notify::is-loading", self.on_is_loading_changed)
        view.connect("load-failed", self.on_load_failed)

    def load_next(self):
        if not self.urls:
            print("All URLs processed", flush=True)
            self.window.close()  # closing the last window makes gtk_application_run() return
            return GLib.SOURCE_REMOVE
        self.current = self.urls.pop(0)
        self.started = False
        self.error_code = None
        self.timed_out = False
        self.timeout_id = GLib.timeout_add_seconds(TIMEOUT_SECONDS, self.on_timeout)
        self.view.load_uri(self.current)
        return GLib.SOURCE_REMOVE

    def done(self, message):
        if self.timeout_id:
            GLib.source_remove(self.timeout_id)
            self.timeout_id = 0
        print(f"{message}: {self.current}", flush=True)
        # Do not call the next load_uri() inside the signal handler; call it from the main loop
        GLib.idle_add(self.load_next)

    def on_is_loading_changed(self, view, _pspec):
        if view.is_loading():
            self.started = True
        elif self.started:
            self.started = False
            if self.timed_out:
                self.done("Stopped after the time limit")
            elif self.error_code is not None:
                self.done(f"Ended without committing (error code {self.error_code})")
            else:
                self.done(f"Finished ({view.get_title() or 'no title'})")

    def on_load_failed(self, view, error_code, failing_uri):
        # Ignore failures from iframes and the like. Chromium reports a normalized URL (for example
        # with a trailing /), so compare with the URI being shown, not only the string you passed
        if failing_uri in (view.get_uri(), self.current) or \
                failing_uri.rstrip("/") == self.current.rstrip("/"):
            self.error_code = error_code

    def on_timeout(self):
        self.timeout_id = 0
        self.timed_out = True
        print(f"Not finished after {TIMEOUT_SECONDS} s, stopping: {self.current}", flush=True)
        self.view.stop()  # move on once is-loading returns to False
        return GLib.SOURCE_REMOVE


def on_activate(app, urls):
    window = Gtk.ApplicationWindow(application=app, title="Sequential Loader")
    window.set_default_size(800, 600)
    view = BlinkGTK.WebView.new()
    window.set_child(view)
    window.present()

    loader = SequentialLoader(window, view, urls)
    window.loader = loader  # keep the loader alive as long as the window
    loader.load_next()


def main():
    urls = sys.argv[1:] or ["https://example.com/", "https://www.gtk.org/"]
    ok, _argv = BlinkGTK.gtk_init(sys.argv[:1])
    if not ok:
        print("Could not initialize BlinkGTK", file=sys.stderr)
        return 1
    app = Gtk.Application(application_id="org.example.SequentialLoader")
    app.connect("activate", on_activate, urls)
    rc = BlinkGTK.gtk_application_run(app)
    BlinkGTK.gtk_shutdown()
    return rc


if __name__ == "__main__":
    sys.exit(main())

Example:

blinkgtk-run python3 sequential_loader.py https://example.com/ https://www.gtk.org/

Complete Browser Implementation

A browser that uses the four signals and switches for JavaScript, images and local storage.

#!/usr/bin/env python3
"""A browser that uses the four signals and setting switches"""
import sys

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("BlinkGTK", "0.1")
from gi.repository import BlinkGTK, Gtk  # noqa: E402


class CompleteBrowser(Gtk.ApplicationWindow):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.set_title("Complete Browser (Python)")
        self.set_default_size(1200, 800)
        self._build_ui()
        self._connect_signals()

    def _build_ui(self):
        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

        # Toolbar
        toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        for side in ("start", "end", "top", "bottom"):
            getattr(toolbar, f"set_margin_{side}")(6)
        self.back_button = Gtk.Button(label="←", sensitive=False)
        self.forward_button = Gtk.Button(label="→", sensitive=False)
        self.reload_button = Gtk.Button(label="↻")
        self.stop_button = Gtk.Button(label="×", sensitive=False)
        self.security_label = Gtk.Label(label="")
        self.url_entry = Gtk.Entry(hexpand=True)
        for widget in (self.back_button, self.forward_button, self.reload_button,
                       self.stop_button, self.security_label, self.url_entry):
            toolbar.append(widget)

        # Setting switches
        settings_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
        settings_box.set_margin_start(6)
        settings_box.set_margin_bottom(6)
        self.js_switch = Gtk.Switch(active=True)
        self.images_switch = Gtk.Switch(active=True)
        self.storage_switch = Gtk.Switch(active=True)
        for label, switch in (("JavaScript:", self.js_switch),
                              ("Images:", self.images_switch),
                              ("Local storage:", self.storage_switch)):
            settings_box.append(Gtk.Label(label=label))
            settings_box.append(switch)

        self.progress_bar = Gtk.ProgressBar(visible=False)

        self.web_view = BlinkGTK.WebView.new()
        self.web_view.set_vexpand(True)

        self.status_label = Gtk.Label(label="Ready", xalign=0.0)
        self.status_label.set_margin_start(6)
        self.status_label.set_margin_bottom(3)

        for widget in (toolbar, settings_box, self.progress_bar,
                       self.web_view, self.status_label):
            vbox.append(widget)
        self.set_child(vbox)

    def _connect_signals(self):
        self.web_view.connect("notify::is-loading", self.on_is_loading_changed)
        self.web_view.connect("load-changed", self.on_load_changed)
        self.web_view.connect("load-failed", self.on_load_failed)
        self.web_view.connect("title-changed", self.on_title_changed)
        self.web_view.connect("uri-changed", self.on_uri_changed)

        self.back_button.connect("clicked", lambda _b: self.web_view.go_back())
        self.forward_button.connect("clicked", lambda _b: self.web_view.go_forward())
        self.reload_button.connect("clicked", lambda _b: self.web_view.reload())
        self.stop_button.connect("clicked", lambda _b: self.web_view.stop())
        self.url_entry.connect("activate", self.on_url_activate)

        # Settings are used from the next load, so reload after a change
        self.js_switch.connect("notify::active", self.on_setting_toggled,
                               self.web_view.set_javascript_enabled)
        self.images_switch.connect("notify::active", self.on_setting_toggled,
                                   self.web_view.set_images_enabled)
        self.storage_switch.connect("notify::active", self.on_setting_toggled,
                                    self.web_view.set_local_storage_enabled)

    def on_setting_toggled(self, switch, _pspec, setter):
        setter(switch.get_active())
        if self.web_view.get_uri():
            self.web_view.reload()

    def on_is_loading_changed(self, web_view, _pspec):
        """Loading indicator and buttons (arrives for every navigation, including links, back and reload)"""
        loading = web_view.is_loading()
        self.progress_bar.set_visible(loading)
        if loading:
            self.progress_bar.pulse()
            self.status_label.set_text("Loading")
        self.stop_button.set_sensitive(loading)
        self.reload_button.set_sensitive(not loading)
        self.back_button.set_sensitive(web_view.can_go_back())
        self.forward_button.set_sensitive(web_view.can_go_forward())

    def on_load_changed(self, web_view, load_event):
        """Used only for the status text"""
        if load_event == BlinkGTK.LoadEvent.FINISHED:
            self.status_label.set_text("Done")

    def on_load_failed(self, web_view, error_code, failing_uri):
        # Ignore failures that are not for the page on screen (such as iframes) before touching the UI
        if failing_uri != (web_view.get_uri() or failing_uri):
            return
        if error_code == -3:
            self.status_label.set_text("Aborted")
        else:
            self.status_label.set_text(f"Could not load (error code {error_code}): {failing_uri}")

    def on_title_changed(self, web_view):
        title = web_view.get_title()
        self.set_title(f"{title} - Complete Browser" if title else "Complete Browser")

    def on_uri_changed(self, web_view):
        uri = web_view.get_uri() or ""
        self.url_entry.set_text(uri)
        if uri.startswith("https://"):
            self.security_label.set_text("HTTPS")
        elif uri.startswith("http://"):
            self.security_label.set_text("HTTP")
        else:
            self.security_label.set_text("")

    def on_url_activate(self, entry):
        url = entry.get_text().strip()
        if url and "://" not in url and not url.startswith(("about:", "data:")):
            url = "https://" + url
        if url:
            self.web_view.load_uri(url)


def on_activate(app, url):
    window = CompleteBrowser(application=app)
    window.present()
    window.web_view.load_uri(url)


def main():
    url = sys.argv[1] if len(sys.argv) > 1 else "https://example.com/"
    ok, _argv = BlinkGTK.gtk_init(sys.argv[:1])
    if not ok:
        print("Could not initialize BlinkGTK", file=sys.stderr)
        return 1
    app = Gtk.Application(application_id="org.example.CompleteBrowser")
    app.connect("activate", on_activate, url)
    rc = BlinkGTK.gtk_application_run(app)
    BlinkGTK.gtk_shutdown()
    return rc


if __name__ == "__main__":
    sys.exit(main())

Summary

This tutorial showed how to handle BlinkGTK events in Python.

What You Learned

Next Steps


Author: BlinkGTK Development Team
License: BSD 3-Clause
Feedback: daisy19@gmail.com