
Developing Gtk Apps
- 105 installs
- 2 repo stars
- Updated August 1, 2026
- mhagrelius/dotfiles
Helps with ai & agent building tasks.
About
developing-gtk-apps is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- developing-gtk-apps
- AI & Agent Building
- AI-coding skill
Developing Gtk Apps by the numbers
- 105 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,212 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mhagrelius/dotfiles --skill developing-gtk-appsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 105 |
|---|---|
| repo stars | ★ 2 |
| Last updated | August 1, 2026 |
| Repository | mhagrelius/dotfiles ↗ |
What it does
Helps with ai & agent building tasks.
Files
Developing GTK Apps
Build robust GTK 4/libadwaita applications with correct architecture, lifecycle, and patterns.
Core principle: Get the foundation right before the UI. Application lifecycle, threading model, and resource management are where most GTK apps break.
Relationship to UI skill: This skill handles architecture and plumbing. For widget selection, layout, and HIG compliance, use designing-gnome-ui.
Decision Flow
| Task | Use |
|---|---|
| Which widget for settings? | designing-gnome-ui |
| How to structure preferences window? | designing-gnome-ui |
| App crashes on startup | THIS SKILL |
| UI freezes during operation | THIS SKILL |
| How to save user preferences | THIS SKILL (GSettings) |
| Signal not firing/memory leak | THIS SKILL |
| Setting up new app boilerplate | THIS SKILL |
| Packaging for Flatpak | THIS SKILL |
What's Current (libadwaita 1.7+, GTK 4.18+)
API deprecations to avoid:
GtkShortcutsWindow→ UseAdwShortcutsDialog(libadwaita 1.8+).dim-labelCSS class → Use.dimmedclass- X11/Broadway backends are deprecated in GTK 4 (removal planned for GTK 5)
New patterns (libadwaita 1.6-1.8):
AdwSpinner- Preferred overGtkSpinnerAdwToggleGroup- Replaces multiple exclusiveGtkToggleButtoninstancesAdwBottomSheet- Persistent bottom sheetsAdwWrapBox- Box that wraps children to new linesAdwInlineViewSwitcher- For cards, sidebars, boxed lists
Application Boilerplate
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Gtk, Adw, Gio
class MyApp(Adw.Application):
def __init__(self):
super().__init__(
application_id="com.example.MyApp",
flags=Gio.ApplicationFlags.DEFAULT_FLAGS
)
def do_activate(self):
win = self.props.active_window
if not win:
win = MyWindow(application=self)
win.present()
class MyWindow(Adw.ApplicationWindow):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.set_default_size(800, 600)
def main():
app = MyApp()
return app.run(None)Application ID Rules
| Rule | Example |
|---|---|
| Reverse domain notation | com.example.AppName |
| Only alphanumeric + dots | org.gnome.TextEditor |
| Min 2 segments | com.myapp (not myapp) |
| Match desktop file | com.example.MyApp.desktop |
Lifecycle Signals
| Signal | When | Use For |
|---|---|---|
startup | Once, app launches | Actions, CSS, GSettings |
activate | Each launch/raise | Create/present window |
shutdown | App exits | Save state, cleanup |
open | Files passed to app | Handle file arguments |
def do_startup(self):
Adw.Application.do_startup(self) # Chain up FIRST
self.setup_actions()Threading - The Critical Rule
GTK is single-threaded. All UI calls MUST happen on the main thread.
# WRONG - will crash
def background_task():
result = slow_computation()
self.label.set_text(result) # CRASH
# RIGHT - use GLib.idle_add
def background_task():
result = slow_computation()
GLib.idle_add(self.label.set_text, result) # Safe
threading.Thread(target=background_task).start()For async patterns with Gio.Task and cancellation, see gtk-patterns-reference.md.
Actions (Quick Reference)
Actions connect UI to behavior. Define at app level (app.action) or window level (win.action).
# In do_startup - app-level action
quit_action = Gio.SimpleAction.new("quit", None)
quit_action.connect("activate", lambda a, p: self.quit())
self.add_action(quit_action)
self.set_accels_for_action("app.quit", ["<Control>q"])
# In window __init__ - window-level action
save_action = Gio.SimpleAction.new("save", None)
save_action.connect("activate", self.on_save)
self.add_action(save_action)
self.get_application().set_accels_for_action("win.save", ["<Control>s"])For stateful actions (toggles), parameterized actions, and menu integration, see gtk-patterns-reference.md.
GSettings (Quick Reference)
Persist user preferences with GSettings. Requires a schema file.
# In app __init__
self.settings = Gio.Settings.new("com.example.MyApp")
# Read/write values
dark = self.settings.get_boolean("dark-mode")
self.settings.set_boolean("dark-mode", True)
# Bind to widget property (auto-syncs)
self.settings.bind("window-width", window, "default-width",
Gio.SettingsBindFlags.DEFAULT)
# React to changes
self.settings.connect("changed::dark-mode", self.on_dark_changed)For schema XML format and installation, see gtk-patterns-reference.md.
Debugging (Quick Reference)
GTK_DEBUG=interactive myapp # Open GTK Inspector (Ctrl+Shift+D)
G_MESSAGES_DEBUG=all myapp # Show all debug messages
G_DEBUG=fatal-criticals myapp # Abort on critical warnings
GSETTINGS_BACKEND=memory myapp # Test without persisting settingsFor full debugging patterns, profiling, and GDB integration, see gtk-debugging-reference.md.
Red Flags - STOP
- Calling UI methods from threads (use
GLib.idle_add) - Missing
do_startupchain-up - Signal handlers without disconnect on destroy
- Blocking operations in signal handlers
- Hardcoded paths instead of XDG directories
- Missing application ID or wrong format
- Using
time.sleep()in main thread - Using
GtkShortcutsWindow(deprecated - useAdwShortcutsDialog) - Using
GtkSpinnerfor libadwaita apps (useAdwSpinner)
Reference Files
| Need | File |
|---|---|
| GObject classes, properties, signals, list models, property bindings, factories | gtk-gobject-reference.md |
| Actions, GSettings, Resources, Blueprint, async file ops | gtk-patterns-reference.md |
| Desktop file, AppStream metadata, Meson, Flatpak, icons, Python deps | gtk-packaging-reference.md |
| Testing with pytest, async testing, headless/CI testing | gtk-testing-reference.md |
| Internationalization, gettext, ngettext plurals, .po files, Blueprint i18n, RTL testing | gtk-i18n-reference.md |
| DBus activation, interface export, background services, Flatpak portals | gtk-dbus-reference.md |
| GTK Inspector, env vars, profiling, memory debugging | gtk-debugging-reference.md |
| UI patterns, widgets, HIG | Use designing-gnome-ui skill |
External References
GTK DBus Reference
DBus activation and background services for GTK 4/libadwaita apps.
When to Use DBus Activation
- App needs to run tasks when not visible (sync, downloads)
- Other apps need to communicate with your app
- System services need to trigger your app (notifications, files)
- Startup performance optimization (delay full UI until needed)
DBus Service File
# data/com.example.MyApp.service
[D-BUS Service]
Name=com.example.MyApp
Exec=/usr/bin/myapp --gapplication-serviceInstall with meson:
# data/meson.build
install_data(
'com.example.MyApp.service',
install_dir: get_option('datadir') / 'dbus-1' / 'services'
)Application with DBus Activation
from gi.repository import Gio, GLib, Adw
class MyApp(Adw.Application):
def __init__(self):
super().__init__(
application_id="com.example.MyApp",
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE
)
self.add_main_option(
"gapplication-service",
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.NONE,
"Run as background service",
None
)
def do_command_line(self, command_line):
options = command_line.get_options_dict()
if options.contains("gapplication-service"):
# Running as service - don't show UI
self.hold() # Keep alive until explicitly released
return 0
# Normal launch - show window
self.activate()
return 0
def do_activate(self):
win = self.props.active_window
if not win:
win = MainWindow(application=self)
win.present()Exporting DBus Interface
# Define interface in XML
DBUS_INTERFACE = """
<node>
<interface name="com.example.MyApp">
<method name="Sync">
<arg type="b" name="result" direction="out"/>
</method>
<method name="AddItem">
<arg type="s" name="title" direction="in"/>
<arg type="s" name="item_id" direction="out"/>
</method>
<property name="ItemCount" type="u" access="read"/>
<signal name="ItemAdded">
<arg type="s" name="item_id"/>
</signal>
</interface>
</node>
"""
class MyApp(Adw.Application):
def __init__(self):
super().__init__(application_id="com.example.MyApp")
self._dbus_id = 0
def do_dbus_register(self, connection, object_path):
# Called when app registers on session bus
introspection = Gio.DBusNodeInfo.new_for_xml(DBUS_INTERFACE)
self._dbus_id = connection.register_object(
object_path,
introspection.interfaces[0],
self._handle_method_call,
self._handle_get_property,
None # set_property handler
)
return Adw.Application.do_dbus_register(self, connection, object_path)
def do_dbus_unregister(self, connection, object_path):
if self._dbus_id:
connection.unregister_object(self._dbus_id)
Adw.Application.do_dbus_unregister(self, connection, object_path)
def _handle_method_call(self, connection, sender, path, interface,
method, params, invocation):
if method == "Sync":
result = self.perform_sync()
invocation.return_value(GLib.Variant("(b)", (result,)))
elif method == "AddItem":
title = params.unpack()[0]
item_id = self.add_item(title)
invocation.return_value(GLib.Variant("(s)", (item_id,)))
# Emit signal
connection.emit_signal(
None, path, interface, "ItemAdded",
GLib.Variant("(s)", (item_id,))
)
else:
invocation.return_error_literal(
Gio.dbus_error_quark(),
Gio.DBusError.UNKNOWN_METHOD,
f"Unknown method: {method}"
)
def _handle_get_property(self, connection, sender, path, interface, prop):
if prop == "ItemCount":
return GLib.Variant("u", self.get_item_count())
return NoneCalling DBus Methods from Other Apps
from gi.repository import Gio, GLib
def call_myapp_sync():
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
result = bus.call_sync(
"com.example.MyApp", # Bus name
"/com/example/MyApp", # Object path
"com.example.MyApp", # Interface
"Sync", # Method
None, # Parameters
GLib.VariantType("(b)"), # Return type
Gio.DBusCallFlags.NONE,
-1, # Timeout (-1 = default)
None # Cancellable
)
return result.unpack()[0]
# Async version
def call_myapp_sync_async(callback):
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
bus.call(
"com.example.MyApp",
"/com/example/MyApp",
"com.example.MyApp",
"Sync",
None,
GLib.VariantType("(b)"),
Gio.DBusCallFlags.NONE,
-1,
None,
callback
)Background Portal (Flatpak)
For Flatpak apps, use the Background portal to request background permission:
from gi.repository import Gio, GLib
def request_background_permission(window):
"""Request permission to run in background."""
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
# Get window handle for portal
handle = "" # Empty for non-portal-aware windows
options = GLib.Variant("a{sv}", {
"reason": GLib.Variant("s", "Sync data in background"),
"autostart": GLib.Variant("b", False),
"commandline": GLib.Variant("as", ["myapp", "--gapplication-service"]),
})
try:
result = bus.call_sync(
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Background",
"RequestBackground",
GLib.Variant("(sa{sv})", (handle, options)),
GLib.VariantType("(o)"),
Gio.DBusCallFlags.NONE,
-1,
None
)
# Result is a request object path for async response
return True
except GLib.Error as e:
print(f"Background permission denied: {e.message}")
return FalseFlatpak manifest permission:
{
"finish-args": [
"--talk-name=org.freedesktop.portal.Background"
]
}Service Lifecycle
class MyApp(Adw.Application):
def __init__(self):
super().__init__(application_id="com.example.MyApp")
self._hold_count = 0
def start_background_task(self):
"""Keep app alive during background work."""
self.hold()
self._hold_count += 1
# Start async work...
def finish_background_task(self):
"""Release hold when work completes."""
self._hold_count -= 1
self.release()
# If no windows and no holds, app will exit
def do_shutdown(self):
# Cleanup background tasks
self.cancel_pending_operations()
Adw.Application.do_shutdown(self)Autostart (Non-Flatpak)
# ~/.config/autostart/com.example.MyApp.desktop
[Desktop Entry]
Type=Application
Name=My App Background Service
Exec=myapp --gapplication-service
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=trueListening for Signals
def listen_for_item_added():
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
def on_signal(connection, sender, path, interface, signal, params):
item_id = params.unpack()[0]
print(f"Item added: {item_id}")
bus.signal_subscribe(
"com.example.MyApp", # Sender
"com.example.MyApp", # Interface
"ItemAdded", # Signal
"/com/example/MyApp", # Path
None, # arg0
Gio.DBusSignalFlags.NONE,
on_signal
)DBus Type Signatures
| Type | Signature | Python |
|---|---|---|
| Boolean | b | bool |
| Int32 | i | int |
| UInt32 | u | int |
| Int64 | x | int |
| UInt64 | t | int |
| Double | d | float |
| String | s | str |
| Object Path | o | str |
| Array | a | list |
| Dict | a{sv} | dict |
| Tuple | (...) | tuple |
# Examples
GLib.Variant("s", "hello") # String
GLib.Variant("i", 42) # Int32
GLib.Variant("as", ["a", "b", "c"]) # Array of strings
GLib.Variant("(si)", ("hello", 42)) # Tuple
GLib.Variant("a{sv}", {"key": GLib.Variant("s", "value")}) # DictGTK Debugging Reference
Environment variables, GTK Inspector, and debugging tools for GTK 4/libadwaita apps.
Environment Variables
# Debug GTK warnings
GTK_DEBUG=interactive myapp # Opens inspector
# Debug GLib
G_MESSAGES_DEBUG=all myapp # All debug messages
G_MESSAGES_DEBUG=Gtk myapp # Only GTK messages
# Debug GSettings
GSETTINGS_BACKEND=memory myapp # Don't persist settings
# Debug threading issues
G_DEBUG=fatal-criticals myapp # Abort on critical warnings
# Force specific display backend
GDK_BACKEND=wayland myapp
GDK_BACKEND=x11 myappGTK Inspector
# Enable inspector in code
Gtk.Window.set_interactive_debugging(True)
# Or press Ctrl+Shift+D in app (if enabled)Inspector Features
- Objects: Browse widget tree, inspect properties
- CSS: Live CSS editing, see applied styles
- Recorder: Record and replay rendering
- Statistics: Frame timing, memory usage
- Actions: View and trigger actions
- Logs: GLib log messages
Adaptive Preview (libadwaita 1.7+)
# Preview app on different device sizes
# Press Ctrl+Shift+M in inspector to open adaptive preview
# Features: device bezels, scaling, screenshotsDebugging Common Issues
UI Not Updating
# Check if on main thread
import threading
print(f"Current thread: {threading.current_thread().name}")
# Force UI update
from gi.repository import GLib
while GLib.MainContext.default().pending():
GLib.MainContext.default().iteration(False)Signal Not Firing
# Check signal exists
from gi.repository import GObject
signal_id = GObject.signal_lookup("clicked", Gtk.Button)
print(f"Signal exists: {signal_id != 0}")
# Trace all signals
def trace_handler(*args):
print(f"Signal received: {args}")
widget.connect("notify", trace_handler)Memory Leaks
import gc
import weakref
# Track object destruction
def check_cleanup():
weak_ref = weakref.ref(widget)
widget.destroy()
gc.collect()
print(f"Widget destroyed: {weak_ref() is None}")CSS Not Applying
# Check CSS load errors
css_provider = Gtk.CssProvider()
try:
css_provider.load_from_string("invalid {")
except GLib.Error as e:
print(f"CSS error: {e.message}")
# Debug CSS classes
for css_class in widget.get_css_classes():
print(f"CSS class: {css_class}")Logging
from gi.repository import GLib
# Set log handler
def log_handler(domain, level, message, user_data):
print(f"[{domain}] {level}: {message}")
GLib.log_set_handler(None, GLib.LogLevelFlags.LEVEL_WARNING, log_handler, None)
# Log from your code
GLib.log("myapp", GLib.LogLevelFlags.LEVEL_DEBUG, "Debug message")
GLib.log("myapp", GLib.LogLevelFlags.LEVEL_WARNING, "Warning message")Profiling
# GTK frame timing
GTK_DEBUG=snapshot myapp
# Sysprof integration
sysprof-cli -c myapp
# Python profiling
python -m cProfile -o profile.out myappInspecting at Runtime
# In a running app, access inspector:
Gtk.Window.set_interactive_debugging(True)
# Inspect widget hierarchy
def print_tree(widget, indent=0):
print(" " * indent + type(widget).__name__)
if hasattr(widget, 'get_first_child'):
child = widget.get_first_child()
while child:
print_tree(child, indent + 1)
child = child.get_next_sibling()
print_tree(window)GDB Integration
# Run with debugger
gdb --args python myapp.py
# Useful GDB commands for GTK
# (gdb) break g_log
# (gdb) break gtk_widget_realize
# (gdb) call gtk_window_set_interactive_debugging(1)Testing Without Display
# Virtual framebuffer for CI
xvfb-run pytest tests/
# Broadway backend (web-based)
GDK_BACKEND=broadway broadwayd :5 &
GDK_BACKEND=broadway BROADWAY_DISPLAY=:5 myapp
# Access at http://localhost:8080GObject Reference for GTK 4/Libadwaita (Python)
Patterns for creating custom GObject classes, properties, signals, and template widgets.
Custom GObject Classes
Basic Subclass
from gi.repository import GObject
class MyModel(GObject.Object):
"""Simple GObject subclass."""
def __init__(self):
super().__init__()
self._name = ""
@GObject.Property(type=str, default="")
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = valueWith Type Registration (Required for Templates)
from gi.repository import GObject, Gtk, Adw
@Gtk.Template(resource="/com/example/MyApp/window.ui")
class MyWindow(Adw.ApplicationWindow):
__gtype_name__ = "MyWindow" # Must match template name
# Template children
header_bar = Gtk.Template.Child()
content_box = Gtk.Template.Child()
def __init__(self, **kwargs):
super().__init__(**kwargs)
@Gtk.Template.Callback()
def on_button_clicked(self, button):
print("Button clicked!")Properties
Basic Property Types
from gi.repository import GObject
class MyObject(GObject.Object):
# String property
@GObject.Property(type=str, default="")
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value
# Integer property with range
@GObject.Property(type=int, minimum=0, maximum=100, default=50)
def progress(self):
return self._progress
@progress.setter
def progress(self, value):
self._progress = value
# Boolean property
@GObject.Property(type=bool, default=False)
def active(self):
return self._active
@active.setter
def active(self, value):
self._active = value
# Float property
@GObject.Property(type=float, default=1.0)
def scale(self):
return self._scale
@scale.setter
def scale(self, value):
self._scale = valueRead-Only Properties
class MyObject(GObject.Object):
@GObject.Property(type=str, flags=GObject.ParamFlags.READABLE)
def computed_value(self):
return f"{self._name}-{self._id}"Object/Boxed Properties
from gi.repository import GObject, Gio
class MyObject(GObject.Object):
# Object property (another GObject)
@GObject.Property(type=Gio.File)
def file(self):
return self._file
@file.setter
def file(self, value):
self._file = value
# GObject.Object for generic objects
@GObject.Property(type=GObject.Object)
def model(self):
return self._model
@model.setter
def model(self, value):
self._model = valueNotify on Change
class MyObject(GObject.Object):
def __init__(self):
super().__init__()
self._items = []
@GObject.Property(type=int, flags=GObject.ParamFlags.READABLE)
def count(self):
return len(self._items)
def add_item(self, item):
self._items.append(item)
self.notify("count") # Manually notify property changedSignals
Defining Custom Signals
from gi.repository import GObject
class MyObject(GObject.Object):
__gsignals__ = {
# Signal with no parameters
"changed": (GObject.SignalFlags.RUN_LAST, None, ()),
# Signal with parameters
"item-added": (GObject.SignalFlags.RUN_LAST, None, (str,)),
# Signal with multiple parameters
"item-moved": (GObject.SignalFlags.RUN_LAST, None, (int, int)),
# Signal with return value
"validate": (GObject.SignalFlags.RUN_LAST, bool, (str,)),
}
def add_item(self, name):
self._items.append(name)
self.emit("item-added", name)
self.emit("changed")
def move_item(self, from_idx, to_idx):
# Move logic...
self.emit("item-moved", from_idx, to_idx)
def set_value(self, value):
if self.emit("validate", value):
self._value = valueConnecting to Signals
def on_item_added(obj, name):
print(f"Added: {name}")
def on_item_moved(obj, from_idx, to_idx):
print(f"Moved from {from_idx} to {to_idx}")
my_object = MyObject()
my_object.connect("item-added", on_item_added)
my_object.connect("item-moved", on_item_moved)Signal with Accumulator
class MyObject(GObject.Object):
__gsignals__ = {
# Stop emission on first True return
"should-close": (
GObject.SignalFlags.RUN_LAST,
bool,
(),
GObject.signal_accumulator_true_handled
),
}Template Classes
With Blueprint UI
// window.blp
using Gtk 4.0;
using Adw 1;
template $MyWindow: Adw.ApplicationWindow {
title: "My App";
content: Adw.ToolbarView {
[top]
Adw.HeaderBar {}
content: Gtk.Box main_box {
orientation: vertical;
spacing: 12;
Gtk.Button save_button {
label: "Save";
clicked => $on_save_clicked();
}
};
};
}# window.py
from gi.repository import Gtk, Adw
@Gtk.Template(resource="/com/example/MyApp/window.ui")
class MyWindow(Adw.ApplicationWindow):
__gtype_name__ = "MyWindow"
main_box = Gtk.Template.Child()
save_button = Gtk.Template.Child()
def __init__(self, **kwargs):
super().__init__(**kwargs)
@Gtk.Template.Callback()
def on_save_clicked(self, button):
self.save()With XML UI
<!-- window.ui -->
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="MyWindow" parent="AdwApplicationWindow">
<property name="title">My App</property>
<child>
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar"/>
</child>
<property name="content">
<object class="GtkBox" id="main_box">
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child>
<object class="GtkButton" id="save_button">
<property name="label">Save</property>
<signal name="clicked" handler="on_save_clicked"/>
</object>
</child>
</object>
</property>
</object>
</child>
</template>
</interface>List Models
Implementing Gio.ListModel
from gi.repository import GObject, Gio
class Item(GObject.Object):
def __init__(self, title):
super().__init__()
self._title = title
@GObject.Property(type=str)
def title(self):
return self._title
class ItemListModel(GObject.Object, Gio.ListModel):
def __init__(self):
super().__init__()
self._items = []
def do_get_item_type(self):
return Item
def do_get_n_items(self):
return len(self._items)
def do_get_item(self, position):
if position < len(self._items):
return self._items[position]
return None
def append(self, item):
position = len(self._items)
self._items.append(item)
self.items_changed(position, 0, 1)
def remove(self, position):
if position < len(self._items):
del self._items[position]
self.items_changed(position, 1, 0)
def clear(self):
n = len(self._items)
self._items.clear()
self.items_changed(0, n, 0)Using with ListView/GridView
# Create model
model = ItemListModel()
model.append(Item("First"))
model.append(Item("Second"))
# Selection model
selection = Gtk.SingleSelection(model=model)
# Factory for items
factory = Gtk.SignalListItemFactory()
def on_setup(factory, list_item):
label = Gtk.Label()
list_item.set_child(label)
def on_bind(factory, list_item):
label = list_item.get_child()
item = list_item.get_item()
label.set_label(item.title)
factory.connect("setup", on_setup)
factory.connect("bind", on_bind)
# List view
list_view = Gtk.ListView(model=selection, factory=factory)Real-World Example: Todo List Model
Complete todo list with persistence, filtering, and property notifications:
from gi.repository import GObject, Gio, GLib
import json
import os
class TodoItem(GObject.Object):
"""A single todo item with observable properties."""
__gsignals__ = {
"changed": (GObject.SignalFlags.RUN_LAST, None, ()),
}
def __init__(self, title="", completed=False, item_id=None):
super().__init__()
self._id = item_id or str(GLib.uuid_string_random())
self._title = title
self._completed = completed
self._created_at = GLib.DateTime.new_now_local().to_unix()
@GObject.Property(type=str)
def id(self):
return self._id
@GObject.Property(type=str, default="")
def title(self):
return self._title
@title.setter
def title(self, value):
if self._title != value:
self._title = value
self.emit("changed")
@GObject.Property(type=bool, default=False)
def completed(self):
return self._completed
@completed.setter
def completed(self, value):
if self._completed != value:
self._completed = value
self.emit("changed")
@GObject.Property(type=int)
def created_at(self):
return self._created_at
def to_dict(self):
return {
"id": self._id,
"title": self._title,
"completed": self._completed,
"created_at": self._created_at,
}
@classmethod
def from_dict(cls, data):
item = cls(
title=data.get("title", ""),
completed=data.get("completed", False),
item_id=data.get("id"),
)
item._created_at = data.get("created_at", item._created_at)
return item
class TodoListModel(GObject.Object, Gio.ListModel):
"""Observable list model for todo items with filtering and persistence."""
__gsignals__ = {
"items-changed-external": (GObject.SignalFlags.RUN_LAST, None, ()),
}
def __init__(self, data_file=None):
super().__init__()
self._items = []
self._item_handlers = {} # Track signal handlers for cleanup
self._data_file = data_file or os.path.join(
GLib.get_user_data_dir(), "myapp", "todos.json"
)
# --- Gio.ListModel interface ---
def do_get_item_type(self):
return TodoItem
def do_get_n_items(self):
return len(self._items)
def do_get_item(self, position):
if 0 <= position < len(self._items):
return self._items[position]
return None
# --- CRUD operations ---
def add(self, title):
"""Add a new todo item."""
item = TodoItem(title=title)
self._connect_item(item)
position = len(self._items)
self._items.append(item)
self.items_changed(position, 0, 1)
self._auto_save()
return item
def remove(self, item):
"""Remove a todo item."""
try:
position = self._items.index(item)
self._disconnect_item(item)
del self._items[position]
self.items_changed(position, 1, 0)
self._auto_save()
return True
except ValueError:
return False
def remove_at(self, position):
"""Remove item at position."""
if 0 <= position < len(self._items):
item = self._items[position]
self._disconnect_item(item)
del self._items[position]
self.items_changed(position, 1, 0)
self._auto_save()
return True
return False
def clear_completed(self):
"""Remove all completed items."""
# Work backwards to avoid index shifting
removed = 0
for i in range(len(self._items) - 1, -1, -1):
if self._items[i].completed:
self._disconnect_item(self._items[i])
del self._items[i]
self.items_changed(i, 1, 0)
removed += 1
if removed > 0:
self._auto_save()
return removed
def reorder(self, from_pos, to_pos):
"""Move item from one position to another."""
if from_pos == to_pos:
return
if not (0 <= from_pos < len(self._items)):
return
if not (0 <= to_pos < len(self._items)):
return
item = self._items.pop(from_pos)
self._items.insert(to_pos, item)
# Notify of changes
min_pos = min(from_pos, to_pos)
max_pos = max(from_pos, to_pos)
self.items_changed(min_pos, max_pos - min_pos + 1, max_pos - min_pos + 1)
self._auto_save()
# --- Item change tracking ---
def _connect_item(self, item):
handler_id = item.connect("changed", self._on_item_changed)
self._item_handlers[item] = handler_id
def _disconnect_item(self, item):
if item in self._item_handlers:
item.disconnect(self._item_handlers[item])
del self._item_handlers[item]
def _on_item_changed(self, item):
"""Called when any item's properties change."""
try:
position = self._items.index(item)
# Notify that item at position changed (removed 1, added 1 = same item updated)
self.items_changed(position, 1, 1)
self._auto_save()
except ValueError:
pass
# --- Computed properties ---
@GObject.Property(type=int, flags=GObject.ParamFlags.READABLE)
def pending_count(self):
return sum(1 for item in self._items if not item.completed)
@GObject.Property(type=int, flags=GObject.ParamFlags.READABLE)
def completed_count(self):
return sum(1 for item in self._items if item.completed)
# --- Persistence ---
def _auto_save(self):
"""Save after changes (debounce in production)."""
self.save()
def save(self):
"""Save todos to disk."""
os.makedirs(os.path.dirname(self._data_file), exist_ok=True)
data = [item.to_dict() for item in self._items]
with open(self._data_file, "w") as f:
json.dump(data, f, indent=2)
def load(self):
"""Load todos from disk."""
if not os.path.exists(self._data_file):
return
try:
with open(self._data_file, "r") as f:
data = json.load(f)
# Clear existing
old_count = len(self._items)
for item in self._items:
self._disconnect_item(item)
self._items.clear()
# Load new
for item_data in data:
item = TodoItem.from_dict(item_data)
self._connect_item(item)
self._items.append(item)
self.items_changed(0, old_count, len(self._items))
except (json.JSONDecodeError, IOError) as e:
print(f"Error loading todos: {e}")
# --- Filtered view for "active" or "completed" tabs ---
def create_filtered_model(model, show_completed=None):
"""Create a filtered view of the todo list.
Args:
model: TodoListModel instance
show_completed: None=all, True=completed only, False=active only
"""
if show_completed is None:
return model
def filter_func(item):
return item.completed == show_completed
custom_filter = Gtk.CustomFilter.new(filter_func)
return Gtk.FilterListModel(model=model, filter=custom_filter)Usage in a window:
class TodoWindow(Adw.ApplicationWindow):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.model = TodoListModel()
self.model.load()
# Selection model for ListView
self.selection = Gtk.NoSelection(model=self.model)
# Factory for todo rows
factory = Gtk.SignalListItemFactory()
factory.connect("setup", self._on_setup)
factory.connect("bind", self._on_bind)
# ListView
self.list_view = Gtk.ListView(model=self.selection, factory=factory)
# ... rest of UI setup
def _on_setup(self, factory, list_item):
row = Adw.ActionRow()
check = Gtk.CheckButton()
row.add_prefix(check)
row.check = check # Store reference
list_item.set_child(row)
def _on_bind(self, factory, list_item):
row = list_item.get_child()
item = list_item.get_item()
row.set_title(item.title)
row.check.set_active(item.completed)
# Bind checkbox to item's completed property
row.check.connect("toggled", lambda c: setattr(item, "completed", c.get_active()))Common Patterns
Weak References for Callbacks
import weakref
from gi.repository import GLib
class MyWindow(Adw.ApplicationWindow):
def start_timer(self):
# Use weak reference to avoid preventing garbage collection
weak_self = weakref.ref(self)
def on_timeout():
self = weak_self()
if self is None:
return False # Stop timer, window was destroyed
self.update()
return True # Continue timer
GLib.timeout_add_seconds(1, on_timeout)Property Change Batching
class MyModel(GObject.Object):
def update_all(self, name, value, active):
# Freeze notifications during batch update
self.freeze_notify()
try:
self.name = name
self.value = value
self.active = active
finally:
self.thaw_notify()
# All notifications sent at once after thawDispose Pattern
class MyObject(GObject.Object):
def __init__(self):
super().__init__()
self._connections = []
self._disposed = False
def connect_to(self, obj, signal, handler):
handler_id = obj.connect(signal, handler)
self._connections.append((obj, handler_id))
return handler_id
def do_dispose(self):
if self._disposed:
return
self._disposed = True
# Disconnect all signal handlers
for obj, handler_id in self._connections:
if obj.handler_is_connected(handler_id):
obj.disconnect(handler_id)
self._connections.clear()
# Chain up
GObject.Object.do_dispose(self)GTK 4 Widget Subclassing
Custom Widget with Properties
from gi.repository import Gtk, GObject
class CustomButton(Gtk.Button):
__gtype_name__ = "CustomButton"
def __init__(self):
super().__init__()
self._count = 0
self.connect("clicked", self._on_clicked)
@GObject.Property(type=int, minimum=0, default=0)
def count(self):
return self._count
@count.setter
def count(self, value):
self._count = value
self.set_label(f"Clicked {value} times")
def _on_clicked(self, button):
self.count += 1Composite Widget
@Gtk.Template(string="""
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="SearchEntry" parent="GtkBox">
<child>
<object class="GtkEntry" id="entry">
<property name="hexpand">true</property>
</object>
</child>
<child>
<object class="GtkButton" id="clear_btn">
<property name="icon-name">edit-clear-symbolic</property>
</object>
</child>
</template>
</interface>
""")
class SearchEntry(Gtk.Box):
__gtype_name__ = "SearchEntry"
entry = Gtk.Template.Child()
clear_btn = Gtk.Template.Child()
__gsignals__ = {
"search-changed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
}
def __init__(self):
super().__init__()
self.entry.connect("changed", self._on_entry_changed)
self.clear_btn.connect("clicked", self._on_clear_clicked)
def _on_entry_changed(self, entry):
self.emit("search-changed", entry.get_text())
def _on_clear_clicked(self, button):
self.entry.set_text("")Type Registration Order
Critical: Types must be registered before they're used in templates.
# main.py
from gi.repository import Gtk, Adw, Gio
# Register types BEFORE creating application
from .widgets.custom_button import CustomButton
from .widgets.search_entry import SearchEntry
from .window import MyWindow # Template uses CustomButton
class MyApp(Adw.Application):
def __init__(self):
super().__init__(application_id="com.example.MyApp")
def do_activate(self):
win = MyWindow(application=self)
win.present()Debugging GObject Issues
# Check if property exists
print(obj.find_property("name"))
# List all properties
for prop in obj.list_properties():
print(f"{prop.name}: {prop.value_type}")
# Check signal existence
print(GObject.signal_lookup("clicked", Gtk.Button))
# Trace signal emissions
def trace_handler(*args):
print(f"Signal emitted with args: {args}")
obj.connect("changed", trace_handler)GTK Internationalization Reference
Internationalization (i18n) patterns for GTK 4/libadwaita apps using gettext.
Project Structure
myapp/
├── po/
│ ├── POTFILES.in # List of files to translate
│ ├── LINGUAS # List of language codes
│ ├── myapp.pot # Template (generated)
│ ├── de.po # German translations
│ └── fr.po # French translations
├── myapp/
│ └── __init__.py # App code with _() calls
└── meson.buildSetup with Meson
# meson.build
project('myapp', version: '1.0.0')
i18n = import('i18n')
# Define gettext domain (usually same as app ID)
gettext_package = 'com.example.MyApp'
# Process translations
subdir('po')
# Pass to app as compile-time constant
conf = configuration_data()
conf.set_quoted('GETTEXT_PACKAGE', gettext_package)
conf.set_quoted('LOCALEDIR', get_option('prefix') / get_option('localedir'))
configure_file(
input: 'config.py.in',
output: 'config.py',
configuration: conf,
install_dir: python_installation.get_install_dir() / 'myapp'
)# po/meson.build
i18n.gettext(gettext_package, preset: 'glib')POTFILES.in
List all files containing translatable strings:
# po/POTFILES.in
myapp/__init__.py
myapp/window.py
myapp/dialogs.py
data/com.example.MyApp.desktop.in
data/com.example.MyApp.metainfo.xml.inLINGUAS
List supported languages:
# po/LINGUAS
de
es
fr
pt_BRApp Initialization
# myapp/__init__.py
import gettext
import locale
import os
# Import from generated config
from .config import GETTEXT_PACKAGE, LOCALEDIR
def setup_i18n():
"""Initialize internationalization."""
# Set up locale
locale.bindtextdomain(GETTEXT_PACKAGE, LOCALEDIR)
locale.textdomain(GETTEXT_PACKAGE)
# Set up gettext
gettext.bindtextdomain(GETTEXT_PACKAGE, LOCALEDIR)
gettext.textdomain(GETTEXT_PACKAGE)
# Install _() globally
gettext.install(GETTEXT_PACKAGE, LOCALEDIR)
# Call early in app startup
setup_i18n()Using Translations in Code
# myapp/window.py
# After setup_i18n(), _() is available globally
class MainWindow(Adw.ApplicationWindow):
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Simple strings
self.set_title(_("My Application"))
# Strings with variables (use format, not f-strings)
toast = Adw.Toast(title=_("Deleted {count} items").format(count=5))
# Plurals
from gettext import ngettext
message = ngettext(
"{count} item selected",
"{count} items selected",
count
).format(count=count)
# Context for disambiguation
from gettext import pgettext
# Same word, different meaning in context
open_file = pgettext("file", "Open") # Open a file
open_door = pgettext("door", "Open") # Open stateTranslating UI Files
Blueprint:
using Gtk 4.0;
using Adw 1;
template $MyWindow: Adw.ApplicationWindow {
// Mark strings with C_() for context or _() for translation
title: _("My Application");
content: Adw.ToolbarView {
[top]
Adw.HeaderBar {
[end]
Gtk.Button {
label: _("Add");
tooltip-text: _("Add a new item");
}
}
};
}XML UI files:
<property name="label" translatable="yes">Add Item</property>
<property name="tooltip-text" translatable="yes" context="button">Open</property>Translating Desktop/Metainfo Files
Desktop file (use .desktop.in):
# data/com.example.MyApp.desktop.in
[Desktop Entry]
Name=My App
Comment=Does something useful
# These get extracted to .pot and translatedMetainfo (use .metainfo.xml.in):
<!-- data/com.example.MyApp.metainfo.xml.in -->
<component type="desktop-application">
<name>My App</name>
<summary>Does something useful</summary>
<description>
<p>Longer description of the app.</p>
</description>
</component>Translation Workflow
# 1. Generate .pot template from sources
cd build
meson compile myapp-pot
# Or manually:
xgettext --files-from=po/POTFILES.in \
--output=po/myapp.pot \
--from-code=UTF-8 \
--add-comments=Translators
# 2. Create new language file
msginit --input=po/myapp.pot \
--output=po/de.po \
--locale=de_DE.UTF-8
# 3. Update existing translations after code changes
msgmerge --update po/de.po po/myapp.pot
# 4. Compile translations (done automatically by meson)
msgfmt po/de.po --output=locale/de/LC_MESSAGES/myapp.moTesting Translations
# Run app in specific language
LANGUAGE=de ./myapp
# Test right-to-left layouts
LANGUAGE=ar ./myapp
# Check for untranslated strings
# (strings still in English when running in German)
LANGUAGE=de G_MESSAGES_DEBUG=all ./myapp 2>&1 | grep -i untranslatedCommon i18n Mistakes
| Mistake | Fix |
|---|---|
| f-strings with _() | Use _("...{var}...").format(var=val) |
| Concatenating strings | Single _() call: _("Hello, {name}!") |
| Splitting sentences | Keep complete sentences in one _() |
| Hardcoded number format | Use locale.format_string() |
| Hardcoded date format | Use GLib.DateTime formatting |
# WRONG - translators can't reorder
label = _("Hello") + ", " + name + "!"
# RIGHT - complete sentence
label = _("Hello, {name}!").format(name=name)
# WRONG - f-string evaluated before _()
toast = Adw.Toast(title=_(f"Deleted {count} items"))
# RIGHT - placeholder in translated string
toast = Adw.Toast(title=_("Deleted {count} items").format(count=count))Number and Date Formatting
import locale
from gi.repository import GLib
# Numbers - respect locale
locale.setlocale(locale.LC_ALL, '')
formatted = locale.format_string("%.2f", 1234.56, grouping=True)
# "1,234.56" in en_US, "1.234,56" in de_DE
# Dates with GLib
dt = GLib.DateTime.new_now_local()
formatted_date = dt.format("%x") # Locale-appropriate date
formatted_time = dt.format("%X") # Locale-appropriate time
# Full datetime
formatted = dt.format(_("%B %d, %Y at %H:%M"))
# Note: wrap format string in _() for translator flexibilityTranslator Comments
Add context for translators:
# Translators: This appears in the header bar title
title = _("Documents")
# Translators: %d is the number of selected items
message = ngettext(
"%d item selected",
"%d items selected",
count
) % countThese comments appear in the .po file to help translators understand context.
GTK Packaging Reference
Desktop files, AppStream metadata, Meson build, and Flatpak packaging for GTK 4/libadwaita apps.
Desktop File (com.example.MyApp.desktop)
[Desktop Entry]
Name=My App
Comment=Does something useful
Exec=myapp
Icon=com.example.MyApp
Terminal=false
Type=Application
Categories=Utility;
StartupNotify=trueAppStream Metadata (com.example.MyApp.metainfo.xml)
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.example.MyApp</id>
<name>My App</name>
<summary>Does something useful</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<description>
<p>Longer description of the app.</p>
</description>
<launchable type="desktop-id">com.example.MyApp.desktop</launchable>
<url type="homepage">https://example.com</url>
<content_rating type="oars-1.1"/>
<releases>
<release version="1.0.0" date="2024-01-01"/>
</releases>
</component>Meson Build (meson.build)
project('myapp', version: '1.0.0')
python = import('python')
py_installation = python.find_installation('python3')
# Install Python files
install_subdir('myapp', install_dir: py_installation.get_install_dir())
# Install data files
install_data('data/com.example.MyApp.desktop',
install_dir: get_option('datadir') / 'applications')
install_data('data/com.example.MyApp.metainfo.xml',
install_dir: get_option('datadir') / 'metainfo')
# Compile schemas
gnome = import('gnome')
gnome.compile_schemas(build_by_default: true)Flatpak Manifest (com.example.MyApp.json)
{
"app-id": "com.example.MyApp",
"runtime": "org.gnome.Platform",
"runtime-version": "47",
"sdk": "org.gnome.Sdk",
"command": "myapp",
"finish-args": [
"--share=ipc",
"--socket=fallback-x11",
"--socket=wayland",
"--device=dri"
],
"cleanup": [
"/include",
"/lib/pkgconfig",
"*.a",
"*.la"
],
"modules": [
{
"name": "myapp",
"buildsystem": "meson",
"sources": [
{
"type": "dir",
"path": "."
}
]
}
]
}Common Flatpak Permissions
| Permission | Arg | When Needed |
|---|---|---|
| Network access | --share=network | API calls, downloads |
| Home folder | --filesystem=home | User files (prefer portals) |
| Host files | --filesystem=host | File manager apps |
| Notifications | --talk-name=org.freedesktop.Notifications | System notifications |
| Secrets | --talk-name=org.freedesktop.secrets | Keyring access |
| Background | --talk-name=org.freedesktop.portal.Background | Background services |
Build and Run Locally
# Build
flatpak-builder --user --install --force-clean build-dir com.example.MyApp.json
# Run
flatpak run com.example.MyApp
# Export bundle
flatpak build-bundle ~/.local/share/flatpak/repo myapp.flatpak com.example.MyAppPython Dependencies in Flatpak
For apps with Python dependencies, add pip modules:
{
"modules": [
{
"name": "python-requests",
"buildsystem": "simple",
"build-commands": [
"pip3 install --prefix=/app --no-deps ."
],
"sources": [
{
"type": "archive",
"url": "https://files.pythonhosted.org/packages/.../requests-2.31.0.tar.gz",
"sha256": "..."
}
]
},
{
"name": "myapp",
"buildsystem": "meson",
"sources": [
{
"type": "dir",
"path": "."
}
]
}
]
}Or use flatpak-pip-generator to generate module definitions:
# Generate module for requests and dependencies
flatpak-pip-generator requests
# Creates python3-requests.json to include in manifestIcons
Install app icon at multiple sizes:
# data/meson.build
icon_sizes = ['16', '32', '48', '64', '128', '256', '512']
foreach size : icon_sizes
install_data(
'icons/hicolor/@0@x@0@/apps/com.example.MyApp.png'.format(size),
install_dir: get_option('datadir') / 'icons' / 'hicolor' / '@0@x@0@'.format(size) / 'apps'
)
endforeach
# Symbolic icon
install_data(
'icons/hicolor/symbolic/apps/com.example.MyApp-symbolic.svg',
install_dir: get_option('datadir') / 'icons' / 'hicolor' / 'symbolic' / 'apps'
)GSettings Schema Installation
# data/meson.build
install_data(
'com.example.MyApp.gschema.xml',
install_dir: get_option('datadir') / 'glib-2.0' / 'schemas'
)
gnome.post_install(glib_compile_schemas: true)Complete data/meson.build
# data/meson.build
# Desktop file
desktop_file = i18n.merge_file(
input: 'com.example.MyApp.desktop.in',
output: 'com.example.MyApp.desktop',
type: 'desktop',
po_dir: '../po',
install: true,
install_dir: get_option('datadir') / 'applications'
)
# Validate desktop file
desktop_utils = find_program('desktop-file-validate', required: false)
if desktop_utils.found()
test('validate-desktop', desktop_utils, args: [desktop_file])
endif
# AppStream metadata
metainfo_file = i18n.merge_file(
input: 'com.example.MyApp.metainfo.xml.in',
output: 'com.example.MyApp.metainfo.xml',
po_dir: '../po',
install: true,
install_dir: get_option('datadir') / 'metainfo'
)
# Validate AppStream
appstreamcli = find_program('appstreamcli', required: false)
if appstreamcli.found()
test('validate-metainfo', appstreamcli, args: ['validate', '--no-net', metainfo_file])
endif
# GSettings schema
install_data(
'com.example.MyApp.gschema.xml',
install_dir: get_option('datadir') / 'glib-2.0' / 'schemas'
)
# DBus service (if needed)
install_data(
'com.example.MyApp.service',
install_dir: get_option('datadir') / 'dbus-1' / 'services'
)GTK Patterns Reference
Common patterns for actions, GSettings, resources, Blueprint, and file operations in GTK 4/libadwaita apps.
Signals and Properties
Connecting Signals
# Standard connection
button.connect("clicked", self.on_button_clicked)
# With user data
button.connect("clicked", self.on_button_clicked, extra_data)
# Connect after (runs after default handler)
widget.connect_after("signal-name", handler)Disconnecting - Prevent Memory Leaks
class MyWindow(Adw.ApplicationWindow):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.handler_ids = []
# Track handlers
handler_id = some_object.connect("changed", self.on_changed)
self.handler_ids.append((some_object, handler_id))
def do_close_request(self):
# Disconnect all handlers
for obj, handler_id in self.handler_ids:
obj.disconnect(handler_id)
return False # Allow closeProperty Bindings
# One-way binding
source.bind_property(
"active", # source property
target, "sensitive", # target object, property
GObject.BindingFlags.SYNC_CREATE
)
# Two-way binding
entry.bind_property(
"text",
model, "name",
GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE
)
# With transform
def transform_to(binding, value):
return value.upper()
source.bind_property_full(
"text", target, "label",
GObject.BindingFlags.SYNC_CREATE,
transform_to, None
)Actions
Application vs Window Actions
class MyApp(Adw.Application):
def do_startup(self):
Adw.Application.do_startup(self)
# App-level actions (app.action-name)
quit_action = Gio.SimpleAction.new("quit", None)
quit_action.connect("activate", lambda a, p: self.quit())
self.add_action(quit_action)
self.set_accels_for_action("app.quit", ["<Control>q"])
class MyWindow(Adw.ApplicationWindow):
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Window-level actions (win.action-name)
save_action = Gio.SimpleAction.new("save", None)
save_action.connect("activate", self.on_save)
self.add_action(save_action)
self.get_application().set_accels_for_action("win.save", ["<Control>s"])Stateful Actions (Toggles, Radio)
# Toggle action
dark_action = Gio.SimpleAction.new_stateful(
"dark-mode",
None,
GLib.Variant.new_boolean(False)
)
dark_action.connect("change-state", self.on_dark_mode_changed)
self.add_action(dark_action)
def on_dark_mode_changed(self, action, value):
action.set_state(value)
is_dark = value.get_boolean()
# Apply dark mode
# Radio action (string state)
view_action = Gio.SimpleAction.new_stateful(
"view",
GLib.VariantType.new("s"),
GLib.Variant.new_string("grid")
)
view_action.connect("change-state", self.on_view_changed)Parameterized Actions
# Action with parameter
open_action = Gio.SimpleAction.new(
"open-item",
GLib.VariantType.new("s") # String parameter
)
open_action.connect("activate", self.on_open_item)
self.add_action(open_action)
def on_open_item(self, action, parameter):
item_id = parameter.get_string()
self.open_item(item_id)
# Trigger from code
self.activate_action("open-item", GLib.Variant.new_string("item-123"))GSettings
Schema Definition (gschemas.xml)
<?xml version="1.0" encoding="UTF-8"?>
<schemalist>
<schema id="com.example.MyApp" path="/com/example/MyApp/">
<key name="window-width" type="i">
<default>800</default>
<summary>Window width</summary>
</key>
<key name="window-height" type="i">
<default>600</default>
</key>
<key name="dark-mode" type="b">
<default>false</default>
</key>
<key name="recent-files" type="as">
<default>[]</default>
</key>
</schema>
</schemalist>Using GSettings
class MyApp(Adw.Application):
def __init__(self):
super().__init__(application_id="com.example.MyApp")
self.settings = Gio.Settings.new("com.example.MyApp")
def do_activate(self):
win = MyWindow(application=self, settings=self.settings)
win.present()
class MyWindow(Adw.ApplicationWindow):
def __init__(self, settings, **kwargs):
super().__init__(**kwargs)
self.settings = settings
# Bind settings to properties
self.settings.bind(
"window-width", self, "default-width",
Gio.SettingsBindFlags.DEFAULT
)
self.settings.bind(
"window-height", self, "default-height",
Gio.SettingsBindFlags.DEFAULT
)
# Read/write manually
dark = self.settings.get_boolean("dark-mode")
self.settings.set_boolean("dark-mode", True)
# Listen for changes
self.settings.connect("changed::dark-mode", self.on_dark_changed)Compile Schemas (Development)
# Compile for local testing
glib-compile-schemas /path/to/schemas/
# Or set search path
export GSETTINGS_SCHEMA_DIR=/path/to/schemas/Resources (GResource)
Resource Definition (resources.xml)
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/com/example/MyApp">
<file preprocess="xml-stripblanks">window.ui</file>
<file>style.css</file>
<file>icons/symbolic/my-icon-symbolic.svg</file>
</gresource>
</gresources>Compile and Load
# Compile resources
glib-compile-resources --target=resources.gresource resources.xml# Load at startup
resource = Gio.Resource.load("resources.gresource")
Gio.resources_register(resource)
# Or compile inline (development)
resource = Gio.resource_load(
os.path.join(os.path.dirname(__file__), "resources.gresource")
)
Gio.resources_register(resource)Using Resources
# Load UI from resource
builder = Gtk.Builder.new_from_resource("/com/example/MyApp/window.ui")
# Load CSS
css_provider = Gtk.CssProvider()
css_provider.load_from_resource("/com/example/MyApp/style.css")
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(),
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)Blueprint (UI Definition)
Blueprint is a modern, declarative markup language for GTK 4 UIs. Cleaner than XML, with IDE support for completion and error checking.
Note: For which widgets to use in your Blueprint templates, see designing-gnome-ui skill.
Blueprint vs XML
<!-- XML (verbose) -->
<object class="AdwHeaderBar">
<child type="end">
<object class="GtkMenuButton">
<property name="icon-name">open-menu-symbolic</property>
</object>
</child>
</object>// Blueprint (concise)
Adw.HeaderBar {
[end]
MenuButton {
icon-name: "open-menu-symbolic";
}
}Setup with Meson
# meson.build
gnome = import('gnome')
blueprints = files(
'ui/window.blp',
'ui/preferences.blp',
)
blueprint_targets = []
foreach blueprint : blueprints
blueprint_targets += gnome.compile_resources(
'@0@'.format(blueprint).replace('.blp', ''),
configure_file(
input: blueprint,
output: '@PLAINNAME@.ui',
command: [
find_program('blueprint-compiler'),
'compile',
'--output', '@OUTPUT@',
'@INPUT@',
],
),
)
endforeachPort Existing XML
# Auto-convert .ui files to .blp
blueprint-compiler port window.uiKey Patterns
using Gtk 4.0;
using Adw 1;
template $MyWindow: Adw.ApplicationWindow {
default-width: 800;
default-height: 600;
content: Adw.ToolbarView {
[top]
Adw.HeaderBar header_bar {}
content: Adw.Clamp {
maximum-size: 600;
child: Gtk.Box {
orientation: vertical;
spacing: 12;
Adw.PreferencesGroup {
title: "Settings";
Adw.SwitchRow dark_switch {
title: "Dark Mode";
}
}
};
};
};
}Property bindings:
Gtk.Label {
label: bind model.name; // One-way binding
}
Gtk.Entry {
text: bind model.value bidirectional; // Two-way
}
Gtk.Button {
sensitive: bind model.count > 0; // Expression
}Complete Blueprint Window Template
// window.blp
using Gtk 4.0;
using Adw 1;
template $MyAppWindow: Adw.ApplicationWindow {
default-width: 800;
default-height: 600;
title: "My App";
content: Adw.ToastOverlay toast_overlay {
child: Adw.ToolbarView {
[top]
Adw.HeaderBar {
[start]
Gtk.Button {
icon-name: "list-add-symbolic";
tooltip-text: "Add Item";
action-name: "win.add";
}
[end]
Gtk.MenuButton {
icon-name: "open-menu-symbolic";
tooltip-text: "Main Menu";
menu-model: primary_menu;
}
}
content: Adw.Clamp {
maximum-size: 600;
child: Gtk.Box {
orientation: vertical;
margin-top: 24;
margin-bottom: 24;
margin-start: 12;
margin-end: 12;
spacing: 24;
Adw.PreferencesGroup {
title: "Items";
Adw.ActionRow {
title: "Example Item";
subtitle: "Click to view";
activatable: true;
[suffix]
Gtk.Image {
icon-name: "go-next-symbolic";
}
}
}
};
};
};
};
}
menu primary_menu {
section {
item {
label: "_Preferences";
action: "app.preferences";
}
item {
label: "_Keyboard Shortcuts";
action: "win.show-help-overlay";
}
item {
label: "_About";
action: "app.about";
}
}
}# window.py - Load Blueprint template
@Gtk.Template(resource="/com/example/MyApp/window.ui")
class MyAppWindow(Adw.ApplicationWindow):
__gtype_name__ = "MyAppWindow"
toast_overlay = Gtk.Template.Child()
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._setup_actions()
def _setup_actions(self):
add_action = Gio.SimpleAction.new("add", None)
add_action.connect("activate", self._on_add)
self.add_action(add_action)
def _on_add(self, action, param):
self.toast_overlay.add_toast(Adw.Toast(title="Item added"))File Operations with Gio
Async File Read
def load_file_async(self, path, callback):
file = Gio.File.new_for_path(path)
file.load_contents_async(None, callback)
def on_file_loaded(self, file, result):
try:
success, contents, etag = file.load_contents_finish(result)
text = contents.decode('utf-8')
self.process_content(text)
except GLib.Error as e:
self.show_error(f"Could not load file: {e.message}")Async File Write
def save_file_async(self, path, content, callback):
file = Gio.File.new_for_path(path)
file.replace_contents_async(
content.encode('utf-8'),
None, # etag
False, # make_backup
Gio.FileCreateFlags.REPLACE_DESTINATION,
None, # cancellable
callback
)
def on_file_saved(self, file, result):
try:
file.replace_contents_finish(result)
self.show_toast("File saved")
except GLib.Error as e:
self.show_error(f"Could not save: {e.message}")XDG Directories
from gi.repository import GLib
# User data (persistent)
data_dir = GLib.get_user_data_dir() # ~/.local/share
app_data = os.path.join(data_dir, "myapp")
# User config
config_dir = GLib.get_user_config_dir() # ~/.config
app_config = os.path.join(config_dir, "myapp")
# Cache (can be deleted)
cache_dir = GLib.get_user_cache_dir() # ~/.cache
app_cache = os.path.join(cache_dir, "myapp")
# Create if needed
os.makedirs(app_data, exist_ok=True)Common Anti-Patterns
Blocking the Main Loop
# WRONG - freezes UI
def on_button_clicked(self, button):
time.sleep(5) # UI frozen
result = requests.get(url) # UI frozen
self.update(result)
# RIGHT - async
def on_button_clicked(self, button):
self.spinner.start()
threading.Thread(target=self._fetch_data).start()
def _fetch_data(self):
result = requests.get(url)
GLib.idle_add(self._on_data_ready, result)
def _on_data_ready(self, result):
self.spinner.stop()
self.update(result)Signal Handler Memory Leaks
# WRONG - handler keeps window alive
self.app.settings.connect("changed", self.on_settings_changed)
# Window never garbage collected
# RIGHT - disconnect on close
def __init__(self):
self.handler_id = self.app.settings.connect("changed", self.on_changed)
def do_close_request(self):
self.app.settings.disconnect(self.handler_id)
return FalseForgetting to Chain Up
# WRONG - breaks parent behavior
class MyApp(Adw.Application):
def do_startup(self):
self.setup_actions()
# Forgot to call parent - app broken
# RIGHT - chain up
class MyApp(Adw.Application):
def do_startup(self):
Adw.Application.do_startup(self) # Chain up first
self.setup_actions()Wrong Settings Bind Flags
# WRONG - allows UI to write back (if read-only setting)
settings.bind("system-setting", widget, "prop", Gio.SettingsBindFlags.DEFAULT)
# RIGHT - read-only binding
settings.bind("system-setting", widget, "prop", Gio.SettingsBindFlags.GET)GTK Testing Reference
Testing GTK 4/libadwaita apps with pytest, including widget testing, async operations, and GSettings.
Test Setup with pytest
# conftest.py
import pytest
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Gtk, Adw, GLib
@pytest.fixture(scope="session", autouse=True)
def gtk_init():
"""Initialize GTK once for all tests."""
# Initialize libadwaita (also initializes GTK)
Adw.init()
yield
@pytest.fixture
def main_context():
"""Provide a GLib main context for async operations."""
context = GLib.MainContext.default()
yield context
def process_pending_events():
"""Process all pending GTK events."""
context = GLib.MainContext.default()
while context.pending():
context.iteration(False)Testing Widgets
# test_widgets.py
import pytest
from gi.repository import Gtk, Adw, GLib
from myapp.widgets import TodoRow
def process_pending_events():
context = GLib.MainContext.default()
while context.pending():
context.iteration(False)
class TestTodoRow:
def test_row_displays_title(self):
row = TodoRow(title="Buy groceries")
process_pending_events()
assert row.get_title() == "Buy groceries"
def test_checkbox_toggles_completed(self):
row = TodoRow(title="Test task")
process_pending_events()
assert not row.completed
row.check_button.set_active(True)
process_pending_events()
assert row.completed
def test_emits_changed_signal(self):
row = TodoRow(title="Test task")
changed_called = False
def on_changed(widget):
nonlocal changed_called
changed_called = True
row.connect("changed", on_changed)
row.set_title("Updated title")
process_pending_events()
assert changed_calledTesting Windows and Dialogs
# test_windows.py
from gi.repository import Gtk, Adw, Gio
from myapp.window import MainWindow
from myapp.app import MyApp
class TestMainWindow:
def test_window_creates(self):
app = MyApp()
window = MainWindow(application=app)
assert window is not None
assert isinstance(window, Adw.ApplicationWindow)
def test_window_has_header_bar(self):
app = MyApp()
window = MainWindow(application=app)
process_pending_events()
# Find header bar in widget tree
content = window.get_content()
assert content is not None
def test_add_action_creates_item(self):
app = MyApp()
window = MainWindow(application=app)
initial_count = window.model.get_n_items()
# Activate the add action
window.activate_action("add", None)
process_pending_events()
assert window.model.get_n_items() == initial_count + 1Testing Async Operations
# test_async.py
from gi.repository import GLib
import threading
def wait_for_condition(condition_func, timeout_ms=1000):
"""Wait for a condition to become true, processing events."""
context = GLib.MainContext.default()
start = GLib.get_monotonic_time()
timeout_us = timeout_ms * 1000
while not condition_func():
if GLib.get_monotonic_time() - start > timeout_us:
raise TimeoutError("Condition not met within timeout")
context.iteration(False)
class TestAsyncOperations:
def test_async_load_completes(self):
loader = DataLoader()
loaded = False
result_data = None
def on_complete(data):
nonlocal loaded, result_data
loaded = True
result_data = data
loader.load_async(on_complete)
# Wait for async operation
wait_for_condition(lambda: loaded, timeout_ms=5000)
assert loaded
assert result_data is not None
def test_cancellation_works(self):
loader = DataLoader()
cancelled = False
def on_complete(data):
pass
def on_cancelled():
nonlocal cancelled
cancelled = True
loader.load_async(on_complete, on_cancelled=on_cancelled)
loader.cancel()
wait_for_condition(lambda: cancelled, timeout_ms=1000)
assert cancelledTesting GSettings
# test_settings.py
import os
import tempfile
from gi.repository import Gio, GLib
class TestSettings:
@pytest.fixture
def memory_settings(self):
"""Use in-memory backend for tests."""
# Set memory backend before creating settings
os.environ["GSETTINGS_BACKEND"] = "memory"
yield Gio.Settings.new("com.example.MyApp")
del os.environ["GSETTINGS_BACKEND"]
def test_default_values(self, memory_settings):
settings = memory_settings
assert settings.get_int("window-width") == 800
assert settings.get_int("window-height") == 600
def test_settings_persist(self, memory_settings):
settings = memory_settings
settings.set_int("window-width", 1024)
# Re-read
assert settings.get_int("window-width") == 1024Testing List Models
# test_models.py
from myapp.models import TodoListModel, TodoItem
class TestTodoListModel:
def test_add_item(self):
model = TodoListModel()
model.add("Test item")
assert model.get_n_items() == 1
assert model.get_item(0).title == "Test item"
def test_items_changed_signal(self):
model = TodoListModel()
changes = []
def on_items_changed(model, position, removed, added):
changes.append((position, removed, added))
model.connect("items-changed", on_items_changed)
model.add("Test item")
assert len(changes) == 1
assert changes[0] == (0, 0, 1) # position=0, removed=0, added=1
def test_remove_item(self):
model = TodoListModel()
item = model.add("Test item")
model.remove(item)
assert model.get_n_items() == 0
def test_filter_model(self):
model = TodoListModel()
model.add("Active task")
completed = model.add("Done task")
completed.completed = True
# Filter to active only
filter_model = Gtk.FilterListModel(model=model)
filter_model.set_filter(
Gtk.CustomFilter.new(lambda item: not item.completed)
)
assert filter_model.get_n_items() == 1
assert filter_model.get_item(0).title == "Active task"Running Tests
# Run all tests
pytest tests/
# Run with GTK debugging
GTK_DEBUG=interactive pytest tests/
# Run specific test file
pytest tests/test_widgets.py
# Run with coverage
pytest --cov=myapp tests/
# Skip slow integration tests
pytest -m "not slow" tests/Test Markers
# conftest.py
import pytest
def pytest_configure(config):
config.addinivalue_line("markers", "slow: marks tests as slow")
config.addinivalue_line("markers", "integration: integration tests")
# test_integration.py
@pytest.mark.slow
@pytest.mark.integration
def test_full_app_lifecycle():
# Slow integration test
passTesting Without Display
For CI environments without a display:
# Use virtual framebuffer
xvfb-run pytest tests/
# Or with environment variable
GDK_BACKEND=broadway pytest tests/Mocking GIO Operations
from unittest.mock import Mock, patch
class TestFileOperations:
def test_load_file_error(self):
window = MainWindow()
# Mock Gio.File to simulate error
with patch('gi.repository.Gio.File.new_for_path') as mock_file:
mock_file.return_value.load_contents_finish.side_effect = GLib.Error(
"File not found"
)
window.load_file("/nonexistent")
process_pending_events()
# Verify error handling
assert window.error_shown