File: mvp.py

package info (click to toggle)
mypaint 2.0.1-9
  • links: PTS, VCS
  • area: main
  • in suites: bookworm
  • size: 27,760 kB
  • sloc: python: 43,902; cpp: 6,920; xml: 2,475; sh: 473; makefile: 26
file content (335 lines) | stat: -rw-r--r-- 11,689 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# This file is part of MyPaint.
# -*- coding: utf-8 -*-
# Copyright (C) 2017 by the MyPaint Development Team.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.

"""Mixin and decorator functionality for the MVP UI pattern."""

# Imports:

import os
import inspect
import abc
import functools
import logging

from lib.gibindings import Gtk


logger = logging.getLogger(__name__)


# Main API classes:

class Presenter:
    """Abstract interface for standardized MVP presenters.

    What's MVP?
    -----------

    "Model-View-Presenter" or "MVP" is a UI pattern where a presenter
    object mediates between a View and a Model. The presenter observes
    changes to both sides, and for each change, updates the other side.
    Presenters encapsulate presentation logic and formatting for a set
    of displayable objects, termed the "view". They also turn user
    actions into internal state changes of obejcts which cannot by
    themselves be shown to the user, called the "model".

    Ref: https://en.wikipedia.org/wiki/Model-view-presenter

    MyPaint MVP conventions
    -----------------------

    In MyPaint, a Presenter's View is a hierarchy of basic GTK objects
    that are typically laid out with the Glade UI designer and stored as
    an XML file. View observation is done with GTK signal callback
    methods, which are named in the XML UI definition, and which are
    expected to be implemented by the corresponding Presenter object.

    In MyPaint, a Presenter's Model can be anything. It's normally one
    or more objects from lib, for example a layer or a whole layers
    stack.  Observation of model objects happens by attaching callbacks
    to their "@event" methods (see lib.observable).

    In the suggested implementation, presenters are tightly bound to
    their view, and often own and instantiate it on demand. See
    BuiltUIPresenter for a mixin class that makes this easy.  They may
    be quite loosely bound to their model if needed, and are typically
    introduced to it during construction. The rest of the code,
    including other presenters, should keep a ref to the presenter for
    as long as the presentation logic needs to happen.

    It is conventional here to name concrete presenter classes like
    "<Model>UI" or "<Role>UI", noting that a presenter provides the
    behavioural aspects of the user interface.

    MyPaint encourages tight-ish coupling of a View to its Presenter(s)
    but looser coupling of Presenters to their Model objects. Presenters
    can own their View. Conversely, presenters almost never own very
    complicated models: most use their view to present aspects of a
    model owned by something else.

    For a presenter observing its model hierarchy, connect methods to
    "@event"s exposed by the highest level model object you can find.
    See "lib.observable" for how this makes garbage collection nicer.
    Conversely, when a presenter needs to observe its view herarchy, use
    standard GTK signals and individual widgets' connect() method.

    Decorators
    ----------

    Use the @model_updater and @view_updater decorators on the
    Presenter's callback methods to save on having to write tons of
    fiddly value tests or implementing other ways of preventing loops.

    Decorate each model observer callback and each view signal handler
    callback to make sure you don't end up with a cascade of calls.
    The callbacks can be specified with or without args.

    >>> class SomeUI (Presenter):
    ...
    ...     @view_updater
    ...     def model_field_A_updated_cb(self, *args, **kwargs):
    ...         pass
    ...
    ...     @view_updater(default=42)
    ...     def model_field_B_updated_cb(self, *args, **kwargs):
    ...         pass
    ...
    ...     @view_updater
    ...     def view_widget_1_updated_cb(self, *args, **kwargs):
    ...         pass
    ...
    ...     @model_updater(default=False)
    ...     def view_widget_2_updated_cb(self, *args, **kwargs):
    ...         pass

    Why do this? It follows from the MVP pattern that if you're not
    careful about the update flow, you can end up with a loop.

    These decorators fix that potential problem by completely skipping
    the wrapped methods if the Presenter is currently doing the *other*
    type of update.  You can return a default value you specify if
    needed: this can keep GTK+ signals happier.

    """

    __metaclass__ = abc.ABCMeta

    @abc.abstractproperty
    def widget(self):
        """The primary widget of the view.

        :returns: the main view widget
        :rtype: Gtk.Widget

        All presenters must expose a single widget to be the primary
        entry point to the view. This property should be overridden to
        provide, for example, the widget to use for the contents of a
        fancy dialog, or the toplevel window in an application.

        For "adoptive" presenters (ones where the Presenter adopts a
        given widget hierarchy as its view, and connects signals to it),
        make the "widget" property *read-write* and do the extra hookup
        work in your "@widget.setter". Your presenter's owner then has a
        clean interface for attaching bundles of additional behaviour to
        parts of its own view.

        """

    @staticmethod
    def _updater(func=None, default=None):
        """Decorates methods that must only be called once at any one time.

        :param callable func: The method to be wrapped.
        :param default: Return value for when the the method is skipped.
        :returns: the decorated method.

        The wrapped function is skipped if it is currently being called
        on the presenter.

        Use this decorator (as model_updater or view_updater) for all
        model or view observer callbacks which update the other side.
        Doing this prevents potential loops.

        See also: lib.observable.event, Gtk.Widget.connect.

        """

        # Allow the @pie syntax to be used with or without args.
        if func is None:
            return functools.partial(Presenter._updater, default=default)

        @functools.wraps(func)
        def method_wrapper(inst, *args, **kwargs):
            try:
                in_call = func.__in_call
            except AttributeError:
                in_call = False

            if in_call:
                logger.debug("@_updater: suppressed a call to %r", func)
                return default

            func.__in_call = True
            try:
                return func(inst, *args, **kwargs)
            except:
                raise
            finally:
                func.__in_call = False

        return method_wrapper


class BuiltUIPresenter (Presenter):
    """Mixin providing Pythonic access to views built from GtkBuilder XML.

    This style of presenter has its view defined entirely by a
    corresponding GTK+ UI XML file. The view is constructed on demand by
    methods here, and its signals will be automatically bound to methods
    of the Presenter when the view objects are constructed. The
    Presenter mixin interface provides overridable hooks for setting the
    initial state of the view.

    Subclasses should define `primary_widget` so as to access and return
    a widget object from `view`.

    """

    _UI_FILE_EXTENSIONS = [".ui", ".glade", ".xml"]

    @property
    def view(self):
        """On-demand access the built View objects by attribute lookup.

        :returns: A wrapper for accessing UI objects.
        :rtype: _ViewWrapper

        When accessed for the first time, this property method
        constructs an internal GtkBuilder, and uses it to instantiate
        GTK objects from a UI XML file.

        The file is expected to reside in the same directory as
        self.__class__.__file___, and to be named after it (*.glade).
        However, if self.__ui_xml__ is defined, that will be used for
        the basename instead.

        Upon construction, the builder connects the new objects' signals
        to self.

        """

        # Cache the loaded UI wrapper.
        try:
            return self.__ui_wrapper
        except AttributeError:
            pass

        # Select a .glade file
        class_defn_file = inspect.getfile(self.__class__)
        mod_dirname = os.path.dirname(class_defn_file)
        try:
            ui_file_basenames = [self.__ui_xml__]
        except AttributeError:
            py_basename = os.path.basename(class_defn_file)
            py_basename, _oldext = os.path.splitext(py_basename)
            ui_file_basenames = [
                py_basename + e
                for e in self._UI_FILE_EXTENSIONS
            ]

        # Load objects, bind, cache and return.
        for basename in ui_file_basenames:
            ui_path = os.path.join(mod_dirname, basename)
            logger.debug("BuiltUIPresenter: trying to load %r", ui_path)
            if not os.path.isfile(ui_path):
                continue
            logger.debug(
                "BuiltUIPresenter: found UI definition in %r; loading",
                ui_path,
            )
            wrapper = _ViewWrapper(ui_path)
            self.__ui_wrapper = wrapper
            self.init_view()
            wrapper._builder.connect_signals(self)
            self.init_view_post_connect()
            return wrapper

        raise RuntimeError(
            "BuiltUIPresenter: could not load "
            "any UI definition XML file (tried %r, in %r)"
            % (ui_file_basenames, mod_dirname),
        )

    def init_view(self):
        """Hook: initialize the view objects (before signal connection).

        Implementations should set the initial state of all relevant UI
        object to reflect the state of the model. It is called before
        signals are connected, for convenience.

        Note that this method is called the first time the view()
        property is accessed.

        This base implementation does nothing. You'll typically want to
        use this rather than init_view_post_connect().

        """

    def init_view_post_connect(self):
        """Hook: initialize the view objects (AFTER signal connection).

        This method is called after connection of signals, but is
        otherwise identical to init_view(). Override that method
        instead, unless you have specific needs.

        """


# Convenience/selfdoc aliases for the updater decorator:

model_updater = Presenter._updater
view_updater = Presenter._updater


# Helper classes:

class _ViewWrapper:
    """Private GTK+ view object abstraction.

    This can be accessed by the ID of object IDs inside the
    corresponding UI XML file.

    """

    _TRANSLATION_DOMAIN = "mypaint"

    def __init__(self, filename):
        self._filename = filename
        builder = Gtk.Builder()
        builder.set_translation_domain(self._TRANSLATION_DOMAIN)
        builder.add_from_file(filename)
        self._builder = builder
        self._cache = {}

    def __getattr__(self, attr_name):
        try:
            return self._cache[attr_name]
        except KeyError:
            pass

        for name in (attr_name, attr_name.replace("_", "-")):
            obj = self._builder.get_object(name)
            if obj is not None:
                self._cache[attr_name] = obj
                return obj

        raise AttributeError(
            "No object with name %r (incl. \"_\" substs) in %r"
            % (attr_name, self._filename),
        )