Window: use GtkTemplate to instantiate from a GtkBuilder template

In C/GTK+, templates allow one to define the UI in .ui files, from which
implementations can be instantiated. This significantly reduces
boilerplate code that creates widgets, sets their desired properties and
connects their signals[1, 2].

For several reasons, this does not work in Python[3]. gi_composites.py[4] is an
implementation in Python that does make this work. It's been used by
several projects for a few months now and none report issues. See the
project's README for more information[5].

Taking these facts into mind, let's experiment for a bit and see how it
can serve Piper.

[1]: https://wiki.gnome.org/HowDoI/CustomWidgets#Templates
[2]: https://blogs.gnome.org/tvb/2013/04/09/announcing-composite-widget-templates/
[3]: https://bugzilla.gnome.org/show_bug.cgi?id=701843
[4]: https://github.com/virtuald/pygi-composite-templates/
[5]: https://github.com/virtuald/pygi-composite-templates/blob/master/README.rst
This commit is contained in:
Jente Hidskes
2017-07-03 16:54:08 +02:00
parent 606a725c7a
commit 8d77e4bf6c
5 changed files with 324 additions and 53 deletions

View File

@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/freedesktop/Piper">
<file preprocess="xml-stripblanks">aboutDialog.ui</file>
<file>404.svg</file>
<!-- Using this alias, GtkApplication will automatically pick it up for us. -->
<file alias="gtk/menus.ui" preprocess="xml-stripblanks">menus.ui</file>
</gresource>
<gresource prefix="/org/freedesktop/Piper">
<file>404.svg</file>
<file preprocess="xml-stripblanks">aboutDialog.ui</file>
<file preprocess="xml-stripblanks">window.ui</file>
<!-- Using this alias, GtkApplication will automatically pick it up for us. -->
<file alias="gtk/menus.ui" preprocess="xml-stripblanks">menus.ui</file>
</gresource>
</gresources>

33
data/window.ui Normal file
View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.20.0 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<template class="ApplicationWindow" parent="GtkApplicationWindow">
<property name="can_focus">False</property>
<child>
<object class="GtkStack" id="stack">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="transition_duration">400</property>
<property name="transition_type">slide-left-right</property>
<child>
<placeholder/>
</child>
</object>
</child>
<child type="titlebar">
<object class="GtkHeaderBar">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="show_close_button">True</property>
<child type="title">
<object class="GtkStackSwitcher" id="stackswitcher">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stack">stack</property>
</object>
</child>
</object>
</child>
</template>
</interface>

View File

@@ -1,10 +1,7 @@
#!/usr/bin/env python3
import gettext
import gi
import locale
import os
import signal
import sys
gi.require_version('Gio', '2.0')
@@ -12,9 +9,8 @@ gi.require_version('Gtk', '3.0')
from gi.repository import Gio, Gtk
@devel@
# Must come after development code injection.
from piper.application import Application
resource = Gio.resource_load(os.path.join('@pkgdatadir@', 'piper.gresource'))
Gio.Resource._register(resource)
def install_excepthook():
@@ -30,6 +26,11 @@ def install_excepthook():
if __name__ == "__main__":
import gettext
import locale
import signal
from piper.application import Application
install_excepthook()
locale.bindtextdomain('piper', '@localedir@')
@@ -37,9 +38,6 @@ if __name__ == "__main__":
gettext.bindtextdomain('piper', '@localedir@')
gettext.textdomain('piper')
resource = Gio.resource_load(os.path.join('@pkgdatadir@', 'piper.gresource'))
Gio.Resource._register(resource)
signal.signal(signal.SIGINT, signal.SIG_DFL)
exit_status = Application().run(sys.argv)
sys.exit(exit_status)

269
piper/gi_composites.py Normal file
View File

@@ -0,0 +1,269 @@
#
# Copyright (C) 2015 Dustin Spicuzza <dustin@virtualroadside.com>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301
# USA
from os.path import abspath, join
import inspect
import warnings
from gi.repository import Gio
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Gtk
__all__ = ['GtkTemplate']
class GtkTemplateWarning(UserWarning):
pass
def _connect_func(builder, obj, signal_name, handler_name,
connect_object, flags, cls):
'''Handles GtkBuilder signal connect events'''
if connect_object is None:
extra = ()
else:
extra = (connect_object,)
# The handler name refers to an attribute on the template instance,
# so ask GtkBuilder for the template instance
template_inst = builder.get_object(cls.__gtype_name__)
if template_inst is None: # This should never happen
errmsg = "Internal error: cannot find template instance! obj: %s; " \
"signal: %s; handler: %s; connect_obj: %s; class: %s" % \
(obj, signal_name, handler_name, connect_object, cls)
warnings.warn(errmsg, GtkTemplateWarning)
return
handler = getattr(template_inst, handler_name)
if flags == GObject.ConnectFlags.AFTER:
obj.connect_after(signal_name, handler, *extra)
else:
obj.connect(signal_name, handler, *extra)
template_inst.__connected_template_signals__.add(handler_name)
def _register_template(cls, template_bytes):
'''Registers the template for the widget and hooks init_template'''
# This implementation won't work if there are nested templates, but
# we can't do that anyways due to PyGObject limitations so it's ok
if not hasattr(cls, 'set_template'):
raise TypeError("Requires PyGObject 3.13.2 or greater")
cls.set_template(template_bytes)
bound_methods = set()
bound_widgets = set()
# Walk the class, find marked callbacks and child attributes
for name in dir(cls):
o = getattr(cls, name, None)
if inspect.ismethod(o):
if hasattr(o, '_gtk_callback'):
bound_methods.add(name)
# Don't need to call this, as connect_func always gets called
# cls.bind_template_callback_full(name, o)
elif isinstance(o, _Child):
cls.bind_template_child_full(name, True, 0)
bound_widgets.add(name)
# Have to setup a special connect function to connect at template init
# because the methods are not bound yet
cls.set_connect_func(_connect_func, cls)
cls.__gtemplate_methods__ = bound_methods
cls.__gtemplate_widgets__ = bound_widgets
base_init_template = cls.init_template
cls.init_template = lambda s: _init_template(s, cls, base_init_template)
def _init_template(self, cls, base_init_template):
'''This would be better as an override for Gtk.Widget'''
# TODO: could disallow using a metaclass.. but this is good enough
# .. if you disagree, feel free to fix it and issue a PR :)
if self.__class__ is not cls:
raise TypeError("Inheritance from classes with @GtkTemplate decorators "
"is not allowed at this time")
connected_signals = set()
self.__connected_template_signals__ = connected_signals
base_init_template(self)
for name in self.__gtemplate_widgets__:
widget = self.get_template_child(cls, name)
self.__dict__[name] = widget
if widget is None:
# Bug: if you bind a template child, and one of them was
# not present, then the whole template is broken (and
# it's not currently possible for us to know which
# one is broken either -- but the stderr should show
# something useful with a Gtk-CRITICAL message)
raise AttributeError("A missing child widget was set using "
"GtkTemplate.Child and the entire "
"template is now broken (widgets: %s)" %
', '.join(self.__gtemplate_widgets__))
for name in self.__gtemplate_methods__.difference(connected_signals):
errmsg = ("Signal '%s' was declared with @GtkTemplate.Callback " +
"but was not present in template") % name
warnings.warn(errmsg, GtkTemplateWarning)
# TODO: Make it easier for IDE to introspect this
class _Child(object):
'''
Assign this to an attribute in your class definition and it will
be replaced with a widget defined in the UI file when init_template
is called
'''
__slots__ = []
@staticmethod
def widgets(count):
'''
Allows declaring multiple widgets with less typing::
button \
label1 \
label2 = GtkTemplate.Child.widgets(3)
'''
return [_Child() for _ in range(count)]
class _GtkTemplate(object):
'''
Use this class decorator to signify that a class is a composite
widget which will receive widgets and connect to signals as
defined in a UI template. You must call init_template to
cause the widgets/signals to be initialized from the template::
@GtkTemplate(ui='foo.ui')
class Foo(Gtk.Box):
def __init__(self):
super(Foo, self).__init__()
self.init_template()
The 'ui' parameter can either be a file path or a GResource resource
path::
@GtkTemplate(ui='/org/example/foo.ui')
class Foo(Gtk.Box):
pass
To connect a signal to a method on your instance, do::
@GtkTemplate.Callback
def on_thing_happened(self, widget):
pass
To create a child attribute that is retrieved from your template,
add this to your class definition::
@GtkTemplate(ui='foo.ui')
class Foo(Gtk.Box):
widget = GtkTemplate.Child()
Note: This is implemented as a class decorator, but if it were
included with PyGI I suspect it might be better to do this
in the GObject metaclass (or similar) so that init_template
can be called automatically instead of forcing the user to do it.
.. note:: Due to limitations in PyGObject, you may not inherit from
python objects that use the GtkTemplate decorator.
'''
__ui_path__ = None
@staticmethod
def Callback(f):
'''
Decorator that designates a method to be attached to a signal from
the template
'''
f._gtk_callback = True
return f
Child = _Child
@staticmethod
def set_ui_path(*path):
'''
If using file paths instead of resources, call this *before*
loading anything that uses GtkTemplate, or it will fail to load
your template file
:param path: one or more path elements, will be joined together
to create the final path
TODO: Alternatively, could wait until first class instantiation
before registering templates? Would need a metaclass...
'''
_GtkTemplate.__ui_path__ = abspath(join(*path))
def __init__(self, ui):
self.ui = ui
def __call__(self, cls):
if not issubclass(cls, Gtk.Widget):
raise TypeError("Can only use @GtkTemplate on Widgets")
# Nested templates don't work
if hasattr(cls, '__gtemplate_methods__'):
raise TypeError("Cannot nest template classes")
# Load the template either from a resource path or a file
# - Prefer the resource path first
try:
template_bytes = Gio.resources_lookup_data(self.ui, Gio.ResourceLookupFlags.NONE)
except GLib.GError:
ui = self.ui
if isinstance(ui, (list, tuple)):
ui = join(ui)
if _GtkTemplate.__ui_path__ is not None:
ui = join(_GtkTemplate.__ui_path__, ui)
with open(ui, 'rb') as fp:
template_bytes = GLib.Bytes.new(fp.read())
_register_template(cls, template_bytes)
return cls
# Future shim support if this makes it into PyGI?
# if hasattr(Gtk, 'GtkTemplate'):
# GtkTemplate = lambda c: c
# else:
GtkTemplate = _GtkTemplate

View File

@@ -14,63 +14,32 @@
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
from gettext import gettext as _
from .gi_composites import GtkTemplate
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
from .mousemap import MouseMap
@GtkTemplate(ui="/org/freedesktop/Piper/window.ui")
class Window(Gtk.ApplicationWindow):
"""A Gtk.ApplicationWindow subclass to implement the main application
window."""
__gtype_name__ = "ApplicationWindow"
stack = GtkTemplate.Child()
def __init__(self, ratbag, *args, **kwargs):
"""Instantiates a new Window.
@param ratbag The ratbag instance to connect to, as ratbagd.Ratbag
"""
Gtk.ApplicationWindow.__init__(self, *args, **kwargs)
self.init_template()
self._ratbag = ratbag
stack = Gtk.Stack()
self.add(stack)
stack.props.homogeneous = True
stack.props.transition_duration = 500
stack.props.transition_type = Gtk.StackTransitionType.SLIDE_LEFT_RIGHT
device = self._fetch_ratbag_device()
stack.add_titled(self._setup_buttons_page(device), "buttons", _("Buttons"))
self.set_titlebar(self._setup_headerbar(stack))
def _setup_headerbar(self, stack):
headerbar = Gtk.HeaderBar()
sizeGroup = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL)
self._quit = Gtk.Button.new_with_mnemonic(_("_Quit"))
self._quit.connect("clicked", lambda button, data: data.destroy(), self)
sizeGroup.add_widget(self._quit)
headerbar.pack_start(self._quit)
switcher = Gtk.StackSwitcher()
switcher.set_stack(stack)
headerbar.set_custom_title(switcher)
return headerbar
def _setup_buttons_page(self, device):
mousemap = MouseMap("#Buttons", device, spacing=20, border_width=20)
sizegroup = Gtk.SizeGroup(Gtk.SizeGroupMode.HORIZONTAL)
profile = device.active_profile
for button in profile.buttons:
mapbutton = Gtk.Button("Button {}".format(button.index))
mousemap.add(mapbutton, "#button{}".format(button.index))
sizegroup.add_widget(mapbutton)
return mousemap
self._device = self._fetch_ratbag_device()
def _fetch_ratbag_device(self):
"""Get the first ratbag device available. If there are multiple