Commits

rkruppe committed d2b37b7

Rename nsgui to gui

Comments (0)

Files changed (42)

     level=loglvl
 )
 
-import ttgx.nsgui.driver
-import ttgx.nsgui.window
-import ttgx.nsgui.model
+import ttgx.gui.driver
+import ttgx.gui.window
+import ttgx.gui.model
 import ttgx.util.path as ttgx_path
 import ttgx.config
 import ttgx.assets
     glEnable(GL_BLEND)
     glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
     
-    window = ttgx.nsgui.window.Window(width=1024, height=700)
+    window = ttgx.gui.window.Window(width=1024, height=700)
     window.setup()
-    driver = ttgx.nsgui.driver.GUIDriver(window)
+    driver = ttgx.gui.driver.GUIDriver(window)
 
-    pages = ttgx.nsgui.model.Page.load_from_path(
+    pages = ttgx.gui.model.Page.load_from_path(
         gsc.gameset_gui_path(), from_yaml.YAMLReader()
     )
     for page in pages:
 import pyglet
 from pyglet.gl import *
-from ttgx.nsgui.window import Window
-from ttgx.nsgui.label import Label
-from ttgx.nsgui.frame import Frame
-from ttgx.nsgui.button import PushButton
-from ttgx.nsgui.driver import GUIDriver
-from ttgx.nsgui.style import Style, Color
+from ttgx.gui.window import Window
+from ttgx.gui.label import Label
+from ttgx.gui.frame import Frame
+from ttgx.gui.button import PushButton
+from ttgx.gui.driver import GUIDriver
+from ttgx.gui.style import Style, Color
 
 
 red_on_green = Style(

ttgx/gui/__init__.py

Empty file added.

ttgx/gui/button.py

+import ttgx
+from ttgx.gui.widget import AbstractWidget
+from ttgx.util import event
+
+
+@event.source
+class PushButton(AbstractWidget):
+    RESOURCES = ('background', 'label')
+    EVENTS = ('RegenBackground', 'RegenLabel')
+
+    def __init__(self, *args, x, y, width, height, text = ''):
+        super().__init__(*args, x=x, y=y, width=width, height=height)
+        self.text = text
+
+    def _propagate_background_size(self, _):
+        self._background.height = self.height
+        self._background.width = self.width
+
+    def _propagate_background_pos(self, _):
+        self._background.PositionChanged.trigger()
+
+    def _propagate_label_size(self, _):
+        self._label.height = self.height
+        self._label.width = self.width
+
+    def _propagate_label_pos(self, _):
+        self._label.PositionChanged.trigger()
+
+    def _setup_background(self):
+        self._background = ttgx.gui.frame.Frame(
+            self, self.style, x=0, y=0, width=self.width, height=self.height
+        )
+        self.SizeChanged.connect(self._propagate_background_size)
+        self.PositionChanged.connect(self._propagate_background_pos)
+        self._background.Clicked.implies(self.Clicked)
+
+    def _teardown_background(self):
+        self.SizeChanged.disconnect(self._propagate_background_size)
+        self.PositionChanged.disconnect(self._propagate_background_pos)
+
+    def _setup_label(self):
+        self._label = ttgx.gui.label.Label(
+            self._background, self.style, x=0, y=0,
+            width=self.width, height=self.height, text=self.text
+        )
+        self.SizeChanged.connect(self._propagate_label_size)
+        self.PositionChanged.connect(self._propagate_label_pos)
+        self._label.Clicked.implies(self.Clicked)
+
+    def _teardown_label(self):
+        self.SizeChanged.disconnect(self._propagate_label_size)
+        self.PositionChanged.disconnect(self._propagate_label_pos)
+
+    def _draw(self):
+        pass
+

ttgx/gui/driver.py

+import pyglet
+from ttgx.util import event
+from ttgx.gui.widget import AbstractWidget
+from ttgx.gui.util import widget_at
+
+
+class GUIDriver:
+    def __init__(self, window):
+        self._window = window
+        pwin = window.pyglet_window_object
+        self._setup_translators(pwin)
+        self._screens = {}
+        self._current_screen = None
+        self._current_widget = None
+
+    def add_screen(self, name, widget):
+        self._screens[name] = widget
+
+    def switch(self, name):
+        if name not in self._screens:
+            raise ValueError("No screen called " + repr(name))
+        if self._current_widget is not None:
+            self._current_widget.teardown()
+            self._window.remove_child(self._current_widget)
+        new_widget = self._screens[name]
+        new_widget.setup()
+        self._current_screen = name
+        self._current_widget = new_widget
+        self._window.add_child(new_widget)
+
+    @property
+    def current_screen(self):
+        return self._current_screen
+
+    @property
+    def screen_names(self):
+        return iter(self._screens)
+
+    ## Pyglet event translation
+
+    def _forward_click(self, x, y, button, _):
+        if button != pyglet.window.mouse.LEFT:
+            return
+        hit = widget_at(self._window, x, y)
+        if hit is not None:
+            relx = x - hit.absolute_x
+            rely = y - hit.absolute_y
+            hit.Clicked.trigger(relx, rely)
+
+    def _setup_translators(self, pwin):
+        pwin.on_mouse_release = self._forward_click
+        pwin.on_key_release = (lambda *_: None)
+        pwin.on_text = (lambda *_: None)
+
+
+EVENT_MAPPING = {
+    'on_mouse_release': AbstractWidget.Clicked,
+    'on_key_release': AbstractWidget.KeyHit,
+    'on_text': AbstractWidget.TextEntered
+}
+

ttgx/gui/frame.py

+import pyglet
+from ttgx.util import event
+from ttgx.gui.widget import AbstractWidget
+
+
+@event.source
+class Frame(AbstractWidget):
+    RESOURCES = ('background',)
+    EVENTS = ('RegenBackground',)
+
+    def _make_vertices(self):
+        top = self.absolute_y + self.height
+        right = self.absolute_x + self.width
+        return (
+            self.absolute_x, self.absolute_y,
+            self.absolute_x, top,
+            right, top,
+            right, top,
+            right, self.absolute_y,
+            self.absolute_x, self.absolute_y
+        )
+
+    def _adjust_background_position(self, _):
+        self._background_vertices.vertices = self._make_vertices()
+
+    def _adjust_background_color(self, _):
+        color = tuple(self.style.background_color)
+        self._background_vertices.colors = color * 6
+
+    def _setup_background(self):
+        # As GL_QUADS etc. are going away and triangles seem preferred,
+        # and because it's actually pretty simple, we construct the background
+        # from two triangles.
+        self._background_vertices = pyglet.graphics.vertex_list(6,
+            ('v2i', self._make_vertices()),
+            ('c4B', tuple(self.style.background_color) * 6)
+        )
+        self.PositionChanged.connect(self._adjust_background_position)
+        self.SizeChanged.connect(self._adjust_background_position)
+        self.StyleChanged.connect(self._adjust_background_color)
+
+    def _teardown_background(self):
+        self._background_vertices.delete()
+        self.PositionChanged.disconnect(self._adjust_background_position)
+        self.SizeChanged.disconnect(self._adjust_background_position)
+        self.StyleChanged.disconnect(self._adjust_background_color)
+
+    def _draw(self):
+        self._background_vertices.draw(pyglet.gl.GL_TRIANGLES)
+

ttgx/gui/label.py

+import pyglet
+from ttgx.gui.widget import AbstractWidget
+from ttgx.util.property import property_triggers
+from ttgx.util import event
+
+@event.source
+class Label(AbstractWidget):
+    EVENTS = ('TextChanged', 'RegenLabel')
+    RESOURCES = ('label',)
+
+    def __init__(self, *args, text='', **kwds):
+        if '\n' in text:
+            raise ValueError("Multi-line text not supported")
+        super().__init__(*args, **kwds)
+        self._text = text
+
+    text = property_triggers('_text', 'TextChanged')
+
+    def _text_changed(self, _):
+        self.RegenLabel.trigger()
+
+    def _propagate_pos(self, _):
+        self._label.x = self.absolute_x
+        self._label.y = self.absolute_y
+
+    def _propagate_size(self, _):
+        self._label.width = self.width
+        self._label.height = self.height
+
+    def _setup_label(self):
+        self._label = pyglet.text.Label(self.text,
+            x=self.absolute_x + self.width // 2,
+            y=self.absolute_y + self.height // 2,
+            color=tuple(self.style.text_color),
+            font_size=self.style.font_size,
+            anchor_x='center', anchor_y='center'
+        )
+        self.TextChanged.connect(self._text_changed)
+        self.PositionChanged.connect(self._propagate_pos)
+        self.SizeChanged.connect(self._propagate_size)
+
+    def _teardown_label(self):
+        self._label.delete()
+        self.TextChanged.disconnect(self._text_changed)
+        self.PositionChanged.disconnect(self._propagate_pos)
+        self.SizeChanged.disconnect(self._propagate_size)
+
+    def _draw(self):
+        self._label.draw()
+

ttgx/gui/model.py

+import os
+
+from ttgx.meta import model as m
+from ttgx.meta import from_yaml, validate
+from ttgx.model.misc import UInt
+import ttgx.gui.frame
+import ttgx.gui.label
+import ttgx.gui.button
+from ttgx.gui.style import Style, Color
+from ttgx.util import split
+import ttgx.meta.algebra as alg
+
+
+class _Widget:
+    def create_widget(self, parent):
+        cls = type(self).CONCRETE_CLASS
+        kwds = {
+            attr: getattr(self, attr)
+            for attr, _ in alg.struct(type(self)).members
+            if attr not in ('style', 'children')
+        }
+        widget = cls(parent, self.style, **kwds)
+        for child in self.children:
+            child.create_widget(widget)
+        return widget
+
+any_widget = m.Union(
+    Button=m.Ref('Button', __name__),
+    Label=m.Ref('Label', __name__),
+    Frame=m.Ref('Frame', __name__)
+)
+
+
+class Frame(m.Model, _Widget):
+    CONCRETE_CLASS = ttgx.gui.frame.Frame
+    x, y, height, width = UInt(), UInt(), UInt(), UInt()
+    style = m.Ref(Style)
+    children = m.List(any_widget)
+
+
+class Label(m.Model, _Widget):
+    CONCRETE_CLASS = ttgx.gui.label.Label
+    x, y, height, width = UInt(), UInt(), UInt(), UInt()
+    style = m.Ref(Style)
+    children = m.List(any_widget)
+
+    text = m.String()
+
+
+class Button(m.Model, _Widget):
+    CONCRETE_CLASS = ttgx.gui.button.PushButton
+    x, y, height, width = UInt(), UInt(), UInt(), UInt()
+    style = m.Ref(Style)
+    children = m.List(any_widget)
+
+    text = m.String()
+
+
+class Page(m.Model):
+    is_value_type = True
+    name = m.String()
+    widgets = m.List(any_widget)
+
+    def create_widget(self):
+        root = ttgx.gui.frame.Frame(None, x=0, y=0, width=600, height=800)
+        for w in self.widgets:
+            w.create_widget(root)
+        return root
+
+    @staticmethod
+    def load_from_path(path, reader):
+        return split.load_from_path(_split_spec, path, reader)
+
+    @staticmethod
+    def save_to_path(objs, path, writer):
+        split.save_to_path(_split_spec, objs, path, writer)
+
+
+_split_spec = split.SplitSpec(Page, {
+    Color: 'colors.yaml',
+    Page: 'pages.yaml',
+    Style: 'styles.yaml',
+    any_widget: 'widgets.yaml'
+})
+

ttgx/gui/style.py

+import ttgx.meta.model as m
+from ttgx.util import event
+from ttgx.util.property import property_triggers
+
+
+def _hex2(x):
+    s = hex(x)[2:]
+    if len(s) == 1:
+        s = '0' + s
+    assert len(s) == 2
+    return s.upper()
+
+
+@event.source
+class Color(m.Model):
+    """
+    Model representation of a color (RGBA, 1 byte each).
+
+    Converting it into a tuple of bytes is done via ``tuple(color)``.
+    Color objects are iterable and yield the RGBA components in that order.
+    Should OpenGL require floats ([0..1]) instead, see :meth:`as_floats`.
+
+    Unlike other models, objects of this class are quasi-immutable
+    (only mutable during deserialization).
+    Changing any property will raise :exc:`TypeError` and have no effect.
+
+    The events are an implementation detail and shouldn't be used.
+    """
+    # A reference type because many elements will share a color and because
+    # "background: color/red" is more readable than writing out the whole
+    # structure (on multiple lines!) each time.
+    # Also, sharing doesn't harm anyone as colors are quasi-immutable.
+    red = green = blue = alpha = m.Integer(0, 256)
+
+    EVENTS = ('_RedChanged', '_BlueChanged', '_GreenChanged', '_AlphaChanged')
+
+    def _on_loaded(self):
+        self._RedChanged.connect(self.__make_mutation_error('_red'))
+        self._GreenChanged.connect(self.__make_mutation_error('_green'))
+        self._BlueChanged.connect(self.__make_mutation_error('_blue'))
+        self._AlphaChanged.connect(self.__make_mutation_error('_alpha'))
+
+    def __make_mutation_error(self, attr):
+        def __mutation_error(obj, old_value):
+            setattr(obj, attr, old_value)
+            raise TypeError("Must not mutate colors")
+        return __mutation_error
+
+    red = property_triggers('_red', '_RedChanged', pass_old_value=True)
+    green = property_triggers('_green', '_GreenChanged', pass_old_value=True)
+    blue = property_triggers('_blue', '_BlueChanged', pass_old_value=True)
+    alpha = property_triggers('_alpha', '_AlphaChanged', pass_old_value=True)
+
+    def __iter__(self):
+        yield self.red
+        yield self.green
+        yield self.blue
+        yield self.alpha
+
+    def as_floats(self):
+        return tuple(x / 255 for x in self)
+
+    def __repr__(self):
+        bytes = (self.red, self.green, self.blue, self.alpha)
+        parts = [_hex2(b) for b in bytes]
+        if all(part[0] == part[1] for part in parts):
+            parts = [part[0] for part in parts]
+        return '#{}{}{}/{}'.format(*parts)
+
+
+@event.source
+class Style(m.Model):
+    EVENTS = ('ValueChanged',)
+
+    #: Color of any text
+    text_color = m.Ref(Color)
+    #: Color of background elements, like the pushable area of a button
+    background_color = m.Ref(Color)
+    #: Font size in pt
+    font_size = m.Integer(0, 100)
+
+    text_color = property_triggers('_text_color', 'ValueChanged')
+    background_color = property_triggers('_background_color', 'ValueChanged')
+    font_size = property_triggers('_font_size', 'ValueChanged')
+

ttgx/gui/tests/__init__.py

Empty file added.

ttgx/gui/tests/test_button.py

+from unittest.mock import Mock, ANY, call, patch
+from ttgx.gui.button import PushButton
+
+GEOMETRY = dict(x=10, y=20, width=30, height=40)
+
+
+class TestCreation:
+    def setup(self):
+        self.button = PushButton(**GEOMETRY)
+
+    def test_widget_attrs_not_broken(self):
+        assert self.button.x == GEOMETRY['x']
+        assert self.button.y == GEOMETRY['y']
+        assert self.button.width == GEOMETRY['width']
+        assert self.button.height == GEOMETRY['height']
+
+    def test_default_empty_text(self):
+        assert self.button.text == ''
+
+
+def test_create_with_text():
+    button = PushButton(text='foo', **GEOMETRY)
+    assert button.text == 'foo'
+
+
+class TestLabelManagement:
+    def setup(self):
+        self.button = PushButton(text='click me!', **GEOMETRY)
+        with patch('ttgx.gui') as mgui:
+            self.button.setup()
+        self.mgui = mgui
+        self.label = mgui.label.Label.return_value
+        self.background = mgui.frame.Frame.return_value
+
+    def test_creates_with_correct_text(self):
+        c = call.label.Label(
+            self.background, ANY, x=ANY, y=ANY,
+            width=ANY, height=ANY, text='click me!'
+        )
+        assert c in self.mgui.mock_calls
+
+    def test_label_is_child_of_background(self):
+        # Children are drawn *after* their parents, so to have the text
+        # appear on top of the background, the background must be parent
+        c = call.label.Label(
+            self.background, ANY, x=ANY, y=ANY,
+            width=ANY, height=ANY, text=ANY
+        )
+        assert c in self.mgui.mock_calls
+
+    def test_no_offset_from_parent(self):
+        c = call.label.Label(
+            self.background, ANY, x=0, y=0, width=ANY, height=ANY, text=ANY
+        )
+        assert c in self.mgui.mock_calls
+
+    def test_passes_size_on(self):
+        c = call.label.Label(
+            self.background, ANY, x=ANY, y=ANY,
+            width=GEOMETRY['width'], height=GEOMETRY['height'], text=ANY
+        )
+        assert c in self.mgui.mock_calls
+
+
+    def test_passes_style(self):
+        c = call.label.Label(
+            self.background, self.button.style, x=ANY, y=ANY,
+            width=ANY, height=ANY, text=ANY
+        )
+        assert c in self.mgui.mock_calls
+
+    def test_height_change_propagates(self):
+        self.button.height += 1
+        assert self.label.height == GEOMETRY['height'] + 1
+
+    def test_width_change_propagates(self):
+        self.button.width += 1
+        assert self.label.width == GEOMETRY['width'] + 1
+
+    def test_pos_change_propagates(self):
+        self.button.PositionChanged.trigger()
+        assert call.PositionChanged.trigger() in self.label.mock_calls
+
+
+class TestBackgroundManagement:
+    setup = TestLabelManagement.setup
+
+    def test_is_parent_of_background(self):
+        c = call.frame.Frame(
+            self.button, ANY, x=ANY, y=ANY, width=ANY, height=ANY
+        )
+        assert c in self.mgui.mock_calls
+
+    def test_creates_with_correct_style(self):
+        c = call.frame.Frame(
+            ANY, self.button.style, x=ANY, y=ANY, width=ANY, height=ANY
+        )
+        assert c in self.mgui.mock_calls
+
+    def test_no_offset_from_parent(self):
+        c = call.frame.Frame(
+            ANY, ANY, x=0, y=0, width=ANY, height=ANY
+        )
+        assert c in self.mgui.mock_calls
+
+    def test_passes_size_on(self):
+        c = call.frame.Frame(
+            ANY, ANY, x=ANY, y=ANY,
+            width=GEOMETRY['width'], height=GEOMETRY['height']
+        )
+        assert c in self.mgui.mock_calls
+
+    def test_height_change_propagates(self):
+        self.button.height += 1
+        assert self.background.height == GEOMETRY['height'] + 1
+
+    def test_width_change_propagates(self):
+        self.button.width += 1
+        assert self.background.width == GEOMETRY['width'] + 1
+
+    def test_pos_change_propagates(self):
+        self.button.PositionChanged.trigger()
+        assert call.PositionChanged.trigger() in self.background.mock_calls
+
+
+def test_click_propagates():
+    button = PushButton(text='click me!', **GEOMETRY)
+    btn_click = button.Clicked = Mock()
+    with patch('ttgx.gui') as mgui:
+        button.setup()
+    label = mgui.label.Label.return_value
+    assert call.Clicked.implies(button.Clicked) in label.mock_calls
+    background = mgui.frame.Frame.return_value
+    assert call.Clicked.implies(button.Clicked) in background.mock_calls
+

ttgx/gui/tests/test_driver.py

+from unittest.mock import Mock, call, patch
+from pytest import raises
+from ttgx.gui.driver import GUIDriver, EVENT_MAPPING
+from ttgx.gui.util import MockWidget
+
+
+def pytest_generate_tests(metafunc):
+    if 'pyglet_event_name' in metafunc.funcargnames:
+        enames = list(EVENT_MAPPING.keys())
+        metafunc.parametrize('pyglet_event_name', enames, ids=enames)
+
+
+def PygletWindowMock():
+    window = Mock()
+    window.pyglet_window_object.event_types = list(EVENT_MAPPING.keys())
+    window.x = window.absolute_x = 0
+    window.y = window.absolute_y = 0
+    window.width = 1024
+    window.height = 786
+    return window
+
+
+class TestDriverSetup:
+    def setup(self):
+        self.window = PygletWindowMock()
+        self.driver = GUIDriver(self.window)
+
+    def test_starts_with_no_screens(self):
+        assert set(self.driver.screen_names) == set()
+
+    def test_current_screen_is_none(self):
+        assert self.driver.current_screen is None
+
+
+class TestSimpleScreens:
+    def setup(self):
+        self.window = PygletWindowMock()
+        self.w1 = MockWidget()
+        self.w2 = MockWidget()
+        self.driver = GUIDriver(self.window)
+        self.driver.add_screen('screen1', self.w1)
+        self.driver.add_screen('screen2', self.w2)
+
+    def test_yields_screen_names_in_any_order(self):
+        assert set(self.driver.screen_names) == {'screen1', 'screen2'}
+
+    def test_can_switch_to_either(self):
+        self.driver.switch('screen1')
+        assert self.driver.current_screen == 'screen1'
+        self.driver.switch('screen2')
+        assert self.driver.current_screen == 'screen2'
+
+    def test_raises_when_switch_to_unknown(self):
+        raises(ValueError, self.driver.switch, 'screen3')
+
+    def test_setup_when_switching(self):
+        self.driver.switch('screen1')
+        assert self.w1.setup_called
+
+    def test_teardown_when_switching(self):
+        self.driver.switch('screen1')
+        self.driver.switch('screen2')
+        assert self.w1.teardown_called
+
+    def test_adds_widget_switched_to(self):
+        self.driver.switch('screen1')
+        assert self.window.mock_calls == [call.add_child(self.w1)]
+
+    def test_removes_widget_switched_from(self):
+        self.driver.switch('screen1')
+        self.driver.switch('screen2')
+        assert self.window.mock_calls[1] == call.remove_child(self.w1)
+
+
+class TestWindowEvents:
+    def setup(self):
+        self.window = PygletWindowMock()
+        self.pwin = self.window.pyglet_window_object
+        self.driver = GUIDriver(self.window)
+
+    def test_overrides_pyglet_event(self, pyglet_event_name):
+        handler = getattr(self.pwin, pyglet_event_name)
+        assert not isinstance(handler, Mock)
+
+
+class TestClickRelay:
+    def setup(self):
+        self.window = PygletWindowMock()
+        self.pwin = self.window.pyglet_window_object
+        self.driver = GUIDriver(self.window)
+        self.root = MockWidget(x=0, y=0, width=110, height=110)
+        self.driver.add_screen('foo', self.root)
+        self.driver.switch('foo')
+
+    def _click(self, x, y, expected, *pos):
+        # We usually do not need .children, but for this case...
+        self.window.children = [self.root]
+        with patch('pyglet.window.mouse.LEFT') as L:
+            self.pwin.on_mouse_release(x, y, L, 0)
+        if expected is not None:
+            assert len(pos) == 2
+            assert expected.Clicked.mock_calls == [call.trigger(*pos)]
+
+    def test_lone_root_hit_1(self):
+        self._click(25, 87, self.root, 25, 87)
+
+    def test_lone_root_hit_2(self):
+        self._click(1, 11, self.root, 1, 11)
+
+    def test_lone_root_hit_3(self):
+        self._click(94, 46, self.root, 94, 46)
+
+    def test_lone_root_not_hit_1(self):
+        self._click(121, 32, None)
+
+    def test_lone_root_not_hit_1(self):
+        self._click(20, 312, None)
+
+    def test_child_hit_1(self):
+        child = MockWidget(self.root, x=10, y=10, width=50, height=50)
+        self._click(25, 25, child, 15, 15)
+
+    def test_child_hit_1(self):
+        child = MockWidget(self.root, x=10, y=10, width=50, height=50)
+        self._click(51, 15, child, 41, 5)
+
+    def test_root_hit_when_child_missed_1(self):
+        child = MockWidget(self.root, x=10, y=10, width=50, height=50)
+        self._click(1, 1, self.root, 1, 1)
+
+    def test_root_hit_when_child_missed_2(self):
+        child = MockWidget(self.root, x=10, y=10, width=50, height=50)
+        self._click(69, 98, self.root, 69, 98)
+
+    def test_does_recurse(self):
+        w1 = MockWidget(self.root, x=10, y=10, width=70, height=80)
+        w2 = MockWidget(w1, x=10, y=10, width=50, height=60)
+        w3 = MockWidget(w2, x=10, y=10, width=30, height=40)
+        self._click(40, 40, w3, 10, 10)
+

ttgx/gui/tests/test_frame.py

+from unittest.mock import Mock, call, ANY
+from ttgx.util.pyglet_patch import patch_pyglet
+from ttgx.gui.frame import Frame
+
+
+class TestBackgroundManagement:
+    def setup(self):
+        self.style = Mock()
+        # This assumes the frame only uses the "call tuple()" way of getting
+        # the RGBA bytes.
+        self.style.background_color = [155, 0, 241, 255]
+        self.frame = Frame(style=self.style, x=0, y=0, width=300, height=400)
+        with patch_pyglet() as mocks:
+            self.frame.setup()
+        self.mg = mocks['graphics']
+        (call,) = self.mg.vertex_list.mock_calls
+        _, self.vertex_args, _ = call
+        self.vertex_list = self.mg.vertex_list.return_value
+
+    def test_creates_vertex_list(self):
+        assert self.mg.mock_calls == [call.vertex_list(2 * 3,
+            ANY, ANY,
+        )]
+
+    def test_passes_right_positions_to_vertex_list(self):
+        fmt, vs = self.vertex_args[1]
+        assert fmt == 'v2i'
+        points = list(zip(vs[::2], vs[1::2]))
+        assert len(points) == 2 * 3
+        assert (0, 0) in points
+        assert (300, 400) in points
+        assert (0, 400) in points
+        assert (300, 0) in points
+
+    def test_passes_background_color_to_vertex_list(self):
+        bc = self.frame.style.background_color
+        fmt, cs = self.vertex_args[2]
+        assert fmt == 'c4B'
+        assert len(cs) == 4 * 6
+        assert cs[0:4] == (155, 0, 241, 255)
+        assert cs[8:12] == cs[0:4]
+        assert cs[-4:] == cs[0:4]
+
+    def test_adjusts_vertices_when_moved(self):
+        self.frame.x += 1
+        vs = self.vertex_list.vertices
+        points = list(zip(vs[::2], vs[1::2]))
+        assert len(points) == 2 * 3
+        assert (1, 0) in points
+        assert (301, 400) in points
+        assert (1, 400) in points
+        assert (301, 0) in points
+
+    def test_adjusts_vertices_when_resized(self):
+        self.frame.width *= 2
+        self.frame.height //= 2
+        vs = self.vertex_list.vertices
+        points = list(zip(vs[::2], vs[1::2]))
+        assert len(points) == 2 * 3
+        assert (0, 0) in points
+        assert (600, 0) in points
+        assert (0, 200) in points
+        assert (600, 200) in points
+
+    def test_adjusts_colors_when_style_changed(self):
+        self.frame.style.background_color = [0, 0, 0, 255]
+        self.frame.StyleChanged.trigger()
+        cs = self.vertex_list.colors
+        assert cs == (0, 0, 0, 255) * 6
+
+    def test_deletes_vertex_list(self):
+        self.frame.delete()
+        assert self.vertex_list.mock_calls == [call.delete()]
+
+    def test_draws_triangles(self):
+        with patch_pyglet() as mocks:
+            self.frame.draw()
+        mgl = mocks['gl']
+        assert self.vertex_list.mock_calls == [
+            call.draw(mgl.GL_TRIANGLES)
+        ]
+
+
+class TestCorrectPositionWithAncestors:
+    def setup(self):
+        self.root = Frame(x=1, y=2, width=800, height=600)
+        self.child = Frame(self.root, x=10, y=20, width=400, height=300)
+        with patch_pyglet() as mocks:
+            self.child.setup()
+        self.mg = mocks['graphics']
+        (call,) = self.mg.vertex_list.mock_calls
+        _, self.vertex_args, _ = call
+        self.vertex_list = self.mg.vertex_list.return_value
+
+    def test_use_absolute_position(self):
+        vs = self.vertex_args[1][1]
+        points = set(zip(vs[::2], vs[1::2]))
+        assert points == { (11, 22), (411, 322), (11, 322), (411, 22) }
+

ttgx/gui/tests/test_invariants.py

+from ttgx.util.pyglet_patch import patch_pyglet
+from ttgx.gui.widget import AbstractWidget
+
+from ttgx.gui.button import PushButton
+from ttgx.gui.frame import Frame
+from ttgx.gui.label import Label
+from ttgx.gui.window import Window
+
+
+WIDGET_CLASSES = {
+    PushButton: {},
+    Frame: {},
+    Label: {'text': ''},
+    Window: {},
+}
+
+for extra in WIDGET_CLASSES.values():
+    extra.update({'x': 10, 'y': 20, 'width': 30, 'height': 40})
+
+del WIDGET_CLASSES[Window]['x']
+del WIDGET_CLASSES[Window]['y']
+
+def pytest_generate_tests(metafunc):
+    metafunc.parametrize(
+        ['SomeWidget', 'kwds'],
+        WIDGET_CLASSES.items(),
+        ids=[cls.__name__ for cls in WIDGET_CLASSES]
+    )
+
+
+def test_teardown_reverses_setup(SomeWidget, kwds):
+    w = SomeWidget(**kwds)
+    for unbound in SomeWidget.SIGNALS:
+        signal = getattr(w, unbound.name)
+        assert not signal.connected
+    with patch_pyglet():
+        w.setup()
+        w.teardown()
+    for unbound in SomeWidget.SIGNALS:
+        signal = getattr(w, unbound.name)
+        print(unbound.name, signal.connected)
+        assert not signal.connected
+

ttgx/gui/tests/test_label.py

+from pytest import raises
+from unittest.mock import Mock, patch, call, ANY
+from ttgx.util.pyglet_patch import patch_pyglet
+from ttgx.gui.util import MockWidget
+from ttgx.gui.label import Label
+
+
+GEOMETRY = {'x': 10, 'y': 20, 'width': 22, 'height': 24}
+
+
+class TestLabelCreation:
+    def setup(self):
+        self.l = Label(**GEOMETRY)
+
+    def test_correct_x(self):
+        assert self.l.x == GEOMETRY['x']
+
+    def test_correct_y(self):
+        assert self.l.y == GEOMETRY['y']
+
+    def test_correct_width(self):
+        assert self.l.width == GEOMETRY['width']
+
+    def test_correct_height(self):
+        assert self.l.height == GEOMETRY['height']
+
+    def test_empty_default_text(self):
+        assert self.l.text == ""
+
+def test_rejects_multiline_text():
+    raises(ValueError, Label, text='line\nbreak', **GEOMETRY)
+
+
+def test_has_text():
+    b = Label(text='Hello World', **GEOMETRY)
+    assert b.text == 'Hello World'
+
+
+def test_text_changable():
+    b = Label(text='foo', **GEOMETRY)
+    b.text = 'bar'
+    assert b.text == 'bar'
+
+
+class TestResourceManagement:
+    def setup(self):
+        self.root = MockWidget(x=1, y=2, width=50, height=50)
+        self.style = Mock()
+        self.style.text_color = range(4)
+        self.l = Label(self.root, self.style, text='Hello World', **GEOMETRY)
+        with patch_pyglet() as mocks:
+            self.l.setup()
+        self.mt = mocks['text']
+        self.pyglet_label = self.mt.Label.return_value
+
+    def test_creates_label_correctly(self):
+        assert self.mt.mock_calls == [call.Label(
+            'Hello World', x=11 + 22 // 2, y=22 + 24 // 2,
+            color=(0, 1, 2, 3), font_size=self.style.font_size,
+            anchor_x='center', anchor_y='center'
+        )]
+
+    def test_recreates_label_on_text_change(self):
+        self.l.RegenLabel = Mock()
+        self.l.text = 'Goodbye, cruel world'
+        assert self.l.RegenLabel.mock_calls == [call.trigger()]
+
+    def test_deletes_label(self):
+        self.l.delete()
+        assert self.pyglet_label.mock_calls == [call.delete()]
+
+    def test_draws(self):
+        self.l.draw()
+        assert self.pyglet_label.mock_calls == [call.draw()]
+
+    def test_propagates_x_change(self):
+        self.l.x = 13
+        assert self.pyglet_label.x == 14
+
+    def test_propagates_y_change(self):
+        self.l.y = 7
+        assert self.pyglet_label.y == 9
+
+    def test_propagates_width_change(self):
+        self.l.width = 25
+        assert self.pyglet_label.width == 25
+
+    def test_propagates_height_change(self):
+        self.l.height = 16
+        assert self.pyglet_label.height == 16
+

ttgx/gui/tests/test_style.py

+from pytest import raises
+from unittest.mock import Mock, call, ANY
+from ttgx.gui.style import Color, Style
+
+
+class TestColor:
+    def test_repr_folds(_):
+        c = Color(0xAA, 0xBB, 0xCC, 0xDD)
+        assert repr(c) == '#ABC/D'
+
+    def test_repr_unfoldable(_):
+        c = Color(0xDE, 0xAD, 0xBE, 0xEF)
+        assert repr(c) == '#DEADBE/EF'
+
+    def test_repr_not_quite_foldable(_):
+        c = Color(0xFF, 0xAD, 0x00, 0x19)
+        assert repr(c) == '#FFAD00/19'
+
+    def test_immutable(_):
+        c = Color(0, 0, 0, 0)
+        for component in ('red', 'green', 'blue', 'alpha'):
+            raises(TypeError, setattr, c, component, 0xFF)
+        assert repr(c) == '#000/0'
+
+    def test_to_tuple(_):
+        c = Color(*range(4))
+        assert tuple(c) == tuple(range(4))
+
+    def test_to_floats(_):
+        c = Color(0, 127, 0x0F, 0xFF)
+        r, g, b, a = c.as_floats()
+        assert r == 0.0
+        assert abs(g - 0.5) < 1e-2
+        assert abs(b - 1 / 16) < 1e-2
+        assert a == 1.0
+
+
+def test_style_has_value_changed_event():
+    style = Style(Mock(), Mock(), 11)
+    assert hasattr(style, 'ValueChanged')
+    assert hasattr(style.ValueChanged, 'trigger')
+
+
+class TestStyleValueChanged:
+    def setup(self):
+        self.style = Style(Mock(), Mock(), 12)
+        self.text_color = self.style.text_color
+        self.background_color = self.style.background_color
+        self.ev = self.style.ValueChanged = Mock()
+
+    def test_text_coor(self):
+        self.style.text_color = Mock()
+        assert self.ev.mock_calls == [call.trigger()]
+
+    def test_background_color(self):
+        self.style.background_color = Mock()
+        assert self.ev.mock_calls == [call.trigger()]
+
+    def test_font_size(self):
+        self.style.font_size = Mock()
+        assert self.ev.mock_calls == [call.trigger()]
+

ttgx/gui/tests/test_util.py

+# Note: Although MockWidget is located in util and its tests go in here
+# (or would go, if there were tests to begin with), it is also used
+# productively for testing other parts of util
+from ttgx.gui.util import MockWidget, widget_at
+
+
+class TestWidgetAt:
+    def setup(self):
+        self.root = MockWidget(x=10, y=10, width=110, height=110)
+
+    def test_lone_root_hit(self):
+        assert widget_at(self.root, 35, 97) is self.root
+        assert widget_at(self.root, 11, 21) is self.root
+        assert widget_at(self.root, 104, 56) is self.root
+
+    def test_lone_root_not_hit(self):
+        assert widget_at(self.root, 0, 0) is None
+        assert widget_at(self.root, 121, 32) is None
+        assert widget_at(self.root, 20, 312) is None
+
+    def test_child_hit(self):
+        child = MockWidget(self.root, x=10, y=10, width=50, height=50)
+        assert widget_at(self.root, 25, 25) is child
+        assert widget_at(self.root, 61, 25) is child
+
+    def test_root_hit_when_child_missed(self):
+        child = MockWidget(self.root, x=10, y=10, width=50, height=50)
+        assert widget_at(self.root, 11, 11) is self.root
+        assert widget_at(self.root, 79, 108) is self.root
+
+    def test_does_recurse(self):
+        w1 = MockWidget(self.root, x=10, y=10, width=70, height=80)
+        w2 = MockWidget(w1, x=10, y=10, width=50, height=60)
+        w3 = MockWidget(w2, x=10, y=10, width=30, height=40)
+        assert widget_at(self.root, 50, 50) is w3
+

ttgx/gui/tests/test_widget.py

+from pytest import raises
+import pytest
+from unittest.mock import Mock, call, ANY
+from ttgx.util.pyglet_patch import patch_pyglet
+from ttgx.gui.util import MockWidget
+from ttgx.gui.widget import AbstractWidget
+from ttgx.gui.style import Style
+import ttgx.meta.algebra as alg
+
+
+class ResourceWidget(AbstractWidget):
+    RESOURCES = ('grail', 'foo_bar')
+
+    _draw = MockWidget._draw
+
+    def __init__(self, *args):
+        super().__init__(*args, x=0, y=0, width=10, height=10)
+        self.RegenGrail = Mock()
+        self.RegenFooBar = Mock()
+        self._setup_grail_mock = Mock()
+        self._teardown_grail_mock = Mock()
+        self._setup_foo_bar = Mock()
+        self._teardown_foo_bar = Mock()
+
+    def setup(self):
+        with patch_pyglet():
+            super().setup()
+
+    def _setup_grail(self):
+        self._setup_grail_mock()
+        assert getattr(self, '_last', 'teardown') == 'teardown'
+        self._last = 'setup'
+
+    def _teardown_grail(self):
+        self._teardown_grail_mock()
+        assert self._last == 'setup'
+        self._last = 'teardown'
+
+
+class SetupTeardownTracker(AbstractWidget):
+    RESOURCES = ('res',)
+
+    _draw = MockWidget._draw
+    RegenRes = Mock()
+    
+    def __init__(self, parent=None):
+        super().__init__(parent, x=0, y=0, width=10, height=10)
+        self.calls = []
+
+    def _setup_res(self):
+        self.calls.append('setup')
+
+    def _teardown_res(self):
+        self.calls.append('teardown')
+
+
+def test_default_style_ok():
+    for member, _ in alg.struct(Style).members:
+        assert getattr(MockWidget.DEFAULT_STYLE, member) is not None
+
+
+class TestFreshWidget:
+    def setup(self):
+        self.w = MockWidget()
+
+    def test_default_style(self):
+        assert self.w.style is MockWidget.DEFAULT_STYLE
+
+    def test_style_change(self):
+        self.w.StyleChanged = Mock()
+        self.w.style = Mock()
+        assert self.w.StyleChanged.mock_calls == [call.trigger()]
+
+    def test_x(self):
+        assert self.w.x == 0
+        self.w.x = 241
+        assert self.w.x == 241
+        assert self.w.PositionChanged.mock_calls == [call.trigger()]
+
+    def test_y(self):
+        assert self.w.y == 0
+        self.w.y = 64
+        assert self.w.y == 64
+        assert self.w.PositionChanged.mock_calls == [call.trigger()]
+
+    def test_height(self):
+        assert self.w.height == 10
+        self.w.height = 99
+        assert self.w.height == 99
+        assert self.w.SizeChanged.mock_calls == [call.trigger()]
+
+    def test_width(self):
+        assert self.w.width == 10
+        self.w.width = 769
+        assert self.w.width == 769
+        assert self.w.SizeChanged.mock_calls == [call.trigger()]
+
+    def test_may_omit_parent(self):
+        assert self.w.parent is None
+
+    def test_is_alive(self):
+        assert not self.w.deleted
+
+    def test_drawing_requires_setup(self):
+        raises(RuntimeError, self.w.draw)
+
+    def test_visible(self):
+        assert self.w.visible
+        self.w.visible = False
+        assert not self.w.visible
+
+    def test_draw_respects_visibility(self):
+        self.w.visible = False
+        self.w.setup()
+        self.w.draw()
+        assert not self.w.draw_called
+
+    def test_root_is_root(self):
+        assert self.w.root is self.w
+
+
+class TestStyleManagement:
+    def setup(self):
+        self.style = Mock()
+        self.w = MockWidget(None, self.style)
+
+    def test_uses_custom_style(self):
+        assert self.w.style is self.style
+
+    def test_does_not_listen_before_setup(self):
+        assert self.style.ValueChanged.mock_calls == []
+
+    def test_listens_to_style_value_change(self):
+        self.w.setup()
+        assert self.style.ValueChanged.mock_calls == [call.connect(ANY)]
+
+    def test_triggers_style_changed_on_value_changed(self):
+        self.w.setup()
+        args, kwds = self.style.ValueChanged.connect.call_args
+        assert not kwds
+        (handler,) = args
+        self.w.StyleChanged = Mock()
+        handler(self.style)
+        assert self.w.StyleChanged.mock_calls == [call.trigger()]
+
+
+class TestParentRelation:
+    def setup(self):
+        self.root = MockWidget()
+        self.child = MockWidget(self.root)
+        self.root.setup()
+
+    def test_has_parent(self):
+        assert self.child.parent is self.root
+
+    def test_parent_knows_children(self):
+        w2 = MockWidget(self.child)
+        assert list(self.root.children) == [self.child]
+        assert list(self.child.children) == [w2]
+
+    def test_has_no_children(self):
+        assert list(self.child.children) == []
+
+    def test_delete_removes_children(self):
+        self.child.delete()
+        assert list(self.root.children) == []
+
+    def test_delete_deletes_children(self):
+        self.root.delete()
+        assert self.child.deleted
+
+    def test_draws_children(self):
+        self.root.draw()
+        assert self.child.draw_called
+
+    def test_invisible_doesnt_draw_children(self):
+        self.root.visible = False
+        self.root.draw()
+        assert not self.child.draw_called
+
+    def test_draw_child_of_invisible(self):
+        self.root.visible = False
+        grandchild = MockWidget(self.child)
+        grandchild.setup()
+        grandchild.draw()
+        assert not grandchild.draw_called
+
+    def test_setup_propagates(self):
+        assert self.child.setup_called
+
+    def test_root(self):
+        grandchild = MockWidget(self.child)
+        assert grandchild.root is self.child.root is self.root
+
+    def test_remove_child_affects_children(self):
+        self.root.remove_child(self.child)
+        assert list(self.root.children) == []
+
+    def test_remove_child_affects_parent(self):
+        self.root.remove_child(self.child)
+        assert self.child.parent is None
+
+    def test_add_child_affects_children(self):
+        c = MockWidget()
+        self.root.add_child(c)
+        assert list(self.root.children) == [self.child, c]
+
+    def test_add_child_affects_parent(self):
+        c = MockWidget()
+        self.root.add_child(c)
+        assert c.parent is self.root
+
+    def test_cannot_steal_child(self):
+        r2 = MockWidget()
+        raises(RuntimeError, r2.add_child, self.child)
+        assert self.child.parent is self.root
+
+    def test_cannot_remove_child_from_unrelated(self):
+        r2 = MockWidget()
+        raises(RuntimeError, r2.remove_child, self.child)
+        assert self.child.parent is self.root
+
+
+class TestDeletedWidgetRaises:
+    def setup(self):
+        self.dead = MockWidget()
+        self.dead.setup()
+        self.dead.delete()
+
+    def test_passed_as_child(self):
+        raises(RuntimeError, MockWidget, self.dead)
+
+    def test_get_children(self):
+        raises(RuntimeError, getattr, self.dead, 'children')
+
+    def test_get_parent(self):
+        raises(RuntimeError, getattr, self.dead, 'parent')
+
+    def test_delete_again(self):
+        raises(RuntimeError, self.dead.delete)
+
+    def test_draw(self):
+        raises(RuntimeError, self.dead.draw)
+
+    def test_setup_again(self):
+        raises(RuntimeError, self.dead.setup)
+
+
+class TestResources:
+    def setup(self):
+        self.w = ResourceWidget()
+        self.w.setup()
+
+    def test_setup_calls_setups(self):
+        self.w._setup_grail_mock.assert_called_once_with()
+        self.w._setup_foo_bar.assert_called_once_with()
+
+    def test_delete_calls_teardowns(self):
+        assert not self.w._teardown_grail_mock.called
+        assert not self.w._teardown_foo_bar.called
+        self.w.delete()
+        assert self.w._teardown_grail_mock.called
+        assert self.w._teardown_foo_bar.called
+
+    def test_delete_with_multiple_children(self):
+        children = [MockWidget(self.w) for _ in range(10)]
+        self.w.delete()
+        for i, child in enumerate(children):
+            assert child.deleted
+
+    def test_teardown_calls_teardowns(self):
+        self.w.teardown()
+        assert self.w._teardown_grail_mock.mock_calls == [call()]
+        assert self.w._teardown_foo_bar.mock_calls == [call()]
+
+    def test_listens_to_regen_events(self):
+        assert self.w.RegenGrail.connect.called
+        assert self.w.RegenFooBar.connect.called
+
+    def test_regen_events_regenerates(self):
+        (args, kwds) = self.w.RegenGrail.connect.call_args
+        assert not kwds
+        (regen,) = args
+        assert self.w._teardown_grail_mock.call_count == 0
+        regen(self.w)
+        assert self.w._setup_grail_mock.call_count == 2
+        assert self.w._teardown_grail_mock.call_count == 1
+
+
+def _construct_and_assign(parent=None, **kwds):
+    w = MockWidget(parent)
+    for k, v in kwds.items():
+        setattr(w, k, v)
+    return w
+
+
+class TestRejectsOutOfRangeGeometry:
+    ctor_and_assign = pytest.mark.parametrize('make_widget',
+        [MockWidget, _construct_and_assign],
+        ids=["ctor", "assign"]
+    )
+
+    @ctor_and_assign
+    def test_rejects_negative_x(self, make_widget):
+        raises(ValueError, make_widget, x=-10)
+
+    @ctor_and_assign
+    def test_rejects_negative_y(self, make_widget):
+        raises(ValueError, make_widget, y=-9)
+
+    @ctor_and_assign
+    def test_rejects_negative_width(self, make_widget):
+        raises(ValueError, make_widget, width=-8)
+
+    @ctor_and_assign
+    def test_rejects_negative_height(self, make_widget):
+        raises(ValueError, make_widget, height=-7)
+
+    @ctor_and_assign
+    def test_rejects_too_wide(self, make_widget):
+        w = MockWidget(width=50)
+        raises(ValueError, make_widget, w, x=20, y=0, width=40)
+
+    @ctor_and_assign
+    def test_rejects_too_high(self, make_widget):
+        w = MockWidget(height=50)
+        raises(ValueError, make_widget, w, x=0, y=20, height=40)
+
+    @ctor_and_assign
+    def test_rejects_too_wide_via_x(self, make_widget):
+        w = MockWidget(width=50)
+        raises(ValueError, make_widget, w, x=20, y=0, width=40)
+
+    @ctor_and_assign
+    def test_rejects_too_high_via_y(self, make_widget):
+        w = MockWidget(height=50)
+        raises(ValueError, make_widget, w, x=0, y=20, height=40)
+
+
+class TestAbsolutePosition:
+    def setup(self):
+        self.root = MockWidget(x=1, y=2, width=200, height=200)
+        self.w = MockWidget(self.root, x=15, y=9, width=100, height=100)
+        self.leaf = MockWidget(self.w, x=4, y=11)
+
+    def test_root_absx_is_x(self):
+        assert self.root.absolute_x == 1
+
+    def test_root_absy_is_y(self):
+        assert self.root.absolute_y == 2
+
+    def test_absx_cannot_be_assigned(self):
+        with raises(AttributeError):
+            self.root.absolute_x = 3
+        assert self.root.absolute_x == 1
+
+    def test_absy_cannot_be_assigned(self):
+        with raises(AttributeError):
+            self.root.absolute_y = 4
+        assert self.root.absolute_y == 2
+
+    def test_absx_includes_ancestor_x(self):
+        assert self.leaf.absolute_x == self.leaf.x + self.w.x + self.root.x
+
+    def test_absy_includes_ancestor_y(self):
+        assert self.leaf.absolute_y == self.leaf.y + self.w.y + self.root.y
+
+
+class TestResourceLifecycle:
+    def setup(self):
+        self.root = SetupTeardownTracker()
+        self.child = SetupTeardownTracker(self.root)
+        self.root.setup()
+        self.root.calls.remove('setup')
+        self.child.calls.remove('setup')
+
+    def test_child_teardown_called_once_by_delete(self):
+        self.root.teardown()
+        assert self.child.calls == ['teardown']
+

ttgx/gui/tests/test_window.py

+import pyglet
+from pytest import raises
+from unittest.mock import Mock, call
+from ttgx.util.pyglet_patch import patch_pyglet
+from ttgx.gui.window import Window
+
+WINDOW_DIMENSIONS = {'width': 800, 'height': 600}
+
+
+class TestWindowInteraction:
+    def setup(self):
+        self.win = Window(**WINDOW_DIMENSIONS)
+        style = Mock()
+        style.background_color.as_floats.return_value = []
+        with patch_pyglet() as mocks:
+            # This shouldn't cause a GL call, but still...
+            self.win.style = style
+            self.win.setup()
+        self.mwin = mocks['window']
+        self.llwin = self.mwin.Window.return_value
+
+    def test_creates_window_correctly(self):
+        assert self.mwin.mock_calls == [call.Window(**WINDOW_DIMENSIONS)]
+
+    def test_assumes_xy(self):
+        assert self.win.x == 0
+        assert self.win.y == 0
+
+    def test_raises_on_setting_xy(self):
+        raises(AttributeError, setattr, self.win, 'x', 100)
+        raises(AttributeError, setattr, self.win, 'y', 5)
+
+    def test_closes_window(self):
+        self.win.delete()
+        assert self.llwin.mock_calls == [call.close()]
+
+    def test_clears_on_draw(self):
+        self.win.draw()
+        assert self.llwin.mock_calls == [call.clear()]
+
+    def test_width_change_delegates(self):
+        self.win.width = 1024
+        assert self.llwin.width == 1024
+
+    def test_height_change_delegates(self):
+        self.win.height = 768
+        assert self.llwin.height == 768
+
+    def test_exposes_underlying(self):
+        assert self.win.pyglet_window_object is self.llwin
+
+
+class TestStyleInteraction:
+    def setup(self):
+        style = self.style = Mock()
+        style.background_color.as_floats.return_value = (0.0, 0.3, 0.6, 1.0)
+        self.win = Window(style, **WINDOW_DIMENSIONS)
+        with patch_pyglet() as mocks:
+            self.win.setup()
+        self.mgl = mocks['gl']
+
+    def test_uses_style_background(self):
+        # One call from setup with default style
+        assert self.mgl.mock_calls == [call.glClearColor(0.0, 0.3, 0.6, 1.0)]
+
+    def test_listens_to_value_changed(self):
+        with patch_pyglet() as mocks:
+            self.win.StyleChanged.trigger()
+        mgl2 = mocks['gl']
+        assert mgl2.mock_calls == [call.glClearColor(0.0, 0.3, 0.6, 1.0)]
+
+
+def test_passes_style_on():
+    style = Mock()
+    w = Window(style, width=200, height=200)
+    assert w.style is style
+
+
+def test_rejects_xy():
+    raises(TypeError, Window, x=100, width=500, height=500)
+    raises(TypeError, Window, y=100, width=500, height=500)
+
+
+def test_rejects_parent():
+    parent = Window(width=500, height=500)
+    style = Mock()
+    raises(TypeError, Window, parent, style, width=400, height=400)
+
+from unittest.mock import Mock
+from ttgx.gui.widget import AbstractWidget
+from ttgx.util.pyglet_patch import patch_pyglet
+import ttgx.util.rect
+
+
+class MockWidget(AbstractWidget):
+    draw_called = False
+
+    def __init__(self, *args, x=0, y=0, width=10, height=10):
+        super().__init__(*args, x=x, y=y, width=width, height=height)
+        self.PositionChanged = Mock()
+        self.SizeChanged = Mock()
+        self.Clicked = Mock()
+
+    def setup(self):
+        with patch_pyglet() as mocks:
+            super().setup()
+        self.mg = mocks['graphics']
+        self.setup_called = True
+
+    def teardown(self):
+        super().teardown()
+        self.teardown_called = True
+
+    def _draw(self):
+        super()._draw()
+        self.draw_called = True
+
+def widget_at(widget, x, y):
+    rect = ttgx.util.rect.Rect(
+        widget.absolute_x, widget.absolute_y, widget.width, widget.height
+    )
+    if (x, y) in rect:
+        for child in widget.children:
+            child_hit = widget_at(child, x, y)
+            if child_hit is not None:
+                return child_hit
+        return widget
+    return None
+

ttgx/gui/widget.py

+from abc import ABCMeta, abstractmethod
+import pyglet
+from ttgx.util import event
+from ttgx.util.property import property_triggers
+from ttgx.gui.style import Style, Color
+
+
+@event.source
+class AbstractWidget(metaclass=ABCMeta):
+    """
+    Abstract base class for widgets.
+
+    Widgets may possess a number of (mostly unmanaged) "resources" which are
+    generated and re-generated based on other attributes.
+    Examples include images, VBOs and pyglet widgets.
+    To ease management and updating of these resources, widgets may define
+    a class-wide property :attr:`RESOURCES`, which is a tuple of strings.
+    These strings are mapped to setup and teardown methods.
+    For instance, ``RESOURCES = ('grail', 'norwegian_blue')`` means the
+    following methods are expected:
+
+    - :meth:`_setup_grail` which creates a grail and stores it on the object,
+      but does not even check if there already is one.
+    - :meth:`_setup_norwegian_blue` which does the same thing for a parrot.
+    - :meth:`_teardown_grail` which removes the existing grail, but does not
+      create a new one.
+      This method may end with ``del self._grail`` to help catch bugs.
+    - :meth:`_teardown_norwegian_blue` which does the same thing for a parrot.
+
+    Moreover, :mod:`events <ttgx.util.event>` called ``RegenGrail`` and
+    ``RegenNorwegianBlue`` are expected, and functions that tear down the
+    existing resource and set up a new one are connected to it on setup.
+
+    When calling setup and teardown methods, :class:`AbstractWidget` takes
+    care to ensure the right order for proper resource handling.
+    That means, resource setups and teardowns are always paired
+    and never nested.
+    Calling setups and teardowns manually may clash with this, and is
+    thus HIGHLY discouraged.
+
+    For drawing, :meth:`_draw` must be overriden.
+    It is not called if the widget or any ancestor widget is invisible.
+    If :meth:`_draw` is called, child widgets are also drawn afterwards.
+    That means: :meth:`_draw` should not draw other widgets, but issue
+    OpenGL commands, draw pyglet widgets, etc.
+    It also means widgets are currently unable to override which child
+    widgets should be drawn - this may change later.
+
+    .. note:: Except for ``parent`` and ``style``, all parameters are
+              keyword-only.
+              Subclasses should only add keyword parameters, these
+              should remain the only positional parameter.
+
+    :param parent: parent widget, or :obj:`None` (default) which denotes
+                   a root widget.
+    :type parent: widget or None
+    :param style: The :class:`style <ttgx.gui.style.Style>` the widget
+                  should use, or :obj:`None` to use :attr:`DEFAULT_STYLE`.
+    :type style: style or None
+    :keyword int x: Position, see property docstring.
+    :keyword int y: Position, see property docstring.
+    :keyword int width: Widget's width in pixels.
+    :keyword int height: Widget's height in pixels.
+
+    .. note:: The OpenGL coordinate system is in effect.
+              The point ``x=20, y=30`` is 20px from the left boundary
+              and 30px from the bottom boundary.
+    """
+    RESOURCES = ()
+    EVENTS = (
+        'PositionChanged', 'SizeChanged', 'StyleChanged',
+        'Clicked', 'KeyHit', 'TextEntered',
+        'AcquireFocus', 'LoseFocus'
+    )
+    DEFAULT_STYLE = Style(
+        background_color=Color(0, 0, 0, 255),
+        text_color=Color(255, 255, 255, 255),
+        font_size=14
+    )
+
+    def __init__(self, parent=None, style=None, *, x, y, width, height):
+        self.__parent = None
+        if parent is not None:
+            parent.add_child(self)
+        self.__children = []
+        self.__alive = True
+        self.__set_up = False
+        # Temporary values to appease the checks in the width/height setters
+        # without making an ugly special case
+        self.__x = self.__y = 0
+        self.width = width
+        self.height = height
+        # At least one subclass (Window) has a good reason to override x/y to
+        # be read-only. As this is an advanced use case, we can accept
+        # less useful error messages.
+        try:
+            self.x = x
+            self.y = y
+        except AttributeError:
+            assert type(self).x is not AbstractWidget.x
+            assert type(self).y is not AbstractWidget.y
+        if style is None:
+            style = self.DEFAULT_STYLE
+        self.__style = style
+        self.visible = True
+
+    @property
+    def x(self):
+        """
+        The distance of the widget's **left border** from the parent
+        widget's left border, in pixels.
+        """
+        return self.__x
+
+    @x.setter
+    def x(self, val):
+        if val < 0:
+            raise ValueError("x must be >= 0, but was {!r}".format(val))
+        if self.parent is not None and val + self.width > self.parent.width:
+            raise ValueError("widget must be entirely within parent")
+        self.__x = val
+        self.PositionChanged.trigger()
+
+    @property
+    def absolute_x(self):
+        """
+        The absolute x position inside the root widgit, taking into account
+        the position of ancestors.
+
+        If the root widget's x is 0, this is useful as an OpenGL screen
+        coordinate.
+        """
+        if self.__parent is None:
+            return self.__x
+        return self.__x + self.parent.absolute_x
+
+    @property
+    def y(self):
+        """
+        The distance of the widget's **bottom border** from the parent
+        widget's bottom border, in pixels.
+        """
+        return self.__y
+
+    @y.setter
+    def y(self, val):
+        if val < 0:
+            raise ValueError("x must be >= 0, but was {!r}".format(val))
+        if self.parent is not None and val + self.height > self.parent.height:
+            raise ValueError("widget must be entirely within parent")
+        self.__y = val
+        self.PositionChanged.trigger()
+
+    @property
+    def absolute_y(self):
+        """
+        The absolute y-position inside the root widget.
+
+        .. seealso:: :attr:`absolute_x`.
+        """
+        if self.__parent is None:
+            return self.__y
+        return self.__y + self.parent.absolute_y
+
+    @property
+    def width(self):
+        return self.__width
+
+    @width.setter
+    def width(self, val):
+        if val < 0:
+            raise ValueError("width must be >= 0, but was {!r}".format(val))
+        if self.parent is not None and self.x + val > self.parent.width:
+            raise ValueError("widget must be entirely within parent")
+        self.__width = val
+        self.SizeChanged.trigger()
+
+    @property
+    def height(self):
+        return self.__height
+
+    @height.setter
+    def height(self, val):
+        if val < 0:
+            raise ValueError("height must be >= 0, but was {!r}".format(val))
+        if self.parent is not None and self.y + val > self.parent.height:
+            raise ValueError("widget must be entirely within parent")
+        self.__height = val
+        self.SizeChanged.trigger()
+
+    #: The associated :class:`~ttgx.gui.style.Style` instance.
+    style = property_triggers('_AbstractWidget__style', 'StyleChanged')
+
+    @property
+    def parent(self):
+        """The parent widget, or None for the root widget."""
+        self.__guard()
+        return self.__parent
+
+    @property
+    def children(self):
+        """A tuple containing all child widgets."""
+        self.__guard()
+        return iter(self.__children)
+
+    @property
+    def root(self):
+        """The ancestor which is also a root widget."""
+        widget = self
+        while widget.parent is not None:
+            widget = widget.parent
+        return widget
+
+    @property
+    def deleted(self):
+        """False if the widget is usable, True if it is dead."""
+        return not self.__alive
+
+    def setup(self):
+        """
+        Create any resources required for drawing.
+        May also connect handlers for keeping these resources up-to-date.
+        See class docstring for details on resources, setup and teardown.
+
+        Widgets should not do anything regarding graphics work before this
+        was called, and thus cannot be drawn before setup.
+        """
+        self.__guard()
+        self.__set_up = True
+        self.__style.ValueChanged.connect(self.__style_mutated)
+        for res in type(self).RESOURCES:
+            self.__resource_setup(res)
+            regen_event = self.__resource_regen_event(res)
+            regen = self.__make_regenerate(res)
+            setattr(self, '_AbstractWidget_regen_handler__' + res, regen)
+            regen_event.connect(regen)
+        for child in self.__children:
+            child.setup()
+
+    def teardown(self):
+        """
+        Revert the effects of :meth:`.setup`.
+        Delete resources and remove event handlers.
+        See class docstring for details on resources, setup and teardown.
+        """
+        self.__teardown_self()
+        for child in self.__children:
+            child.teardown()
+
+    def __teardown_self(self):
+        for res in type(self).RESOURCES:
+            self.__resource_teardown(res)
+            regen = getattr(self, '_AbstractWidget_regen_handler__' + res)
+            delattr(self, '_AbstractWidget_regen_handler__' + res)
+            regen_event = self.__resource_regen_event(res)
+            regen_event.disconnect(regen)
+
+    def delete(self):
+        """
+        Remove the widget from its parent and tear down resources.
+
+        Deleted widgets are effectively garbage and raise RuntimeErrors on
+        most methods and properties (except checking :attr:`deleted`).
+        Any child widgets are deleted too.
+        """
+        self.__guard()
+        if self.__parent is not None:
+            self.__parent.remove_child(self)
+        self.__teardown_self()
+        for child in self.__children[:]:
+            child.delete()
+        self.__alive = False
+
+    def __style_mutated(self, _):
+        self.StyleChanged.trigger()
+
+    def __resource_setup(self, res):
+        getattr(self, '_setup_' + res)()
+
+    def __resource_teardown(self, res):
+        getattr(self, '_teardown_' + res)()
+
+    def __resource_regen_event(self, res):
+        parts = map(str.capitalize, res.split('_'))
+        event_name = 'Regen' + ''.join(parts)
+        return getattr(self, event_name)
+
+    def __make_regenerate(self, res):
+        def regenerate_resource(obj):
+            obj.__resource_teardown(res)
+            obj.__resource_setup(res)
+        return regenerate_resource
+
+    def __should_be_hidden(self):
+        widget = self
+        while widget is not None:
+            if not widget.visible:
+                return True
+            widget = widget.parent
+        return False
+
+    def __guard(self):
+        if not self.__alive:
+            raise RuntimeError("{} was already deleted".format(self))
+
+    def add_child(self, child):
+        self.__guard()
+        if child.__parent is None:
+            self.__children.append(child)
+            child.__parent = self
+        else:
+            raise RuntimeError("Widget which already has a parent")
+
+    def remove_child(self, child):
+        self.__guard()
+        if child.parent is self:
+            self.__children.remove(child)
+            child.__parent = None
+        else:
+            assert child not in self.__children
+            raise RuntimeError("Child does not belong to this widget")
+
+    def draw(self):
+        self.__guard()
+        if not self.__set_up:
+            raise RuntimeError("Drawing before UI was set up")
+        if self.__should_be_hidden():
+            return
+        self._draw()
+        for child in self.__children:
+            child.draw()
+
+    @abstractmethod
+    def _draw(self):
+        pass
+

ttgx/gui/window.py

+import pyglet
+from ttgx.gui.widget import AbstractWidget
+from ttgx.util import event
+
+
+@event.source
+class Window(AbstractWidget):
+    """
+    A widget representing a pyglet window.
+
+    .. warning:: Unlike all other widgets, windows do not accept x, y and
+                 parent parameters.
+
+    It does not make sense for a window to have a parent widget, hence the
+    parent is always :obj:`None`.
+    Therefore, if a window is involved in a widget tree, it must be the root.
+    One can still have multiple unrelated windows, and other widgets
+    can also act as roots.
+
+    As for x and y, they are assumed to be 0 and may not be re-assigned.
+    Forcing the OS to place a window at a particular point is nontrivial,
+    and probably bad style anyway.
+    Assuming ``x=0, y=0`` also simplifies the calulation of absolute OpenGL
+    positions (which are independent from the positon of the window and
+    assume the lower left corner is at ``x=0, y=0``).
+    """
+
+    RESOURCES = ('window',)
+    EVENTS = ('RegenWindow',)
+
+    def __init__(self, style=None, *, width, height):
+        super().__init__(None, style, x=0, y=0, width=width, height=height)
+
+    @property
+    def x(self):
+        return 0
+
+    @property
+    def y(self):
+        return 0
+
+    @property
+    def pyglet_window_object(self):
+        """The underlying window object. For advanced use only!"""
+        return self._win
+
+    def _update_window_size(self, _):
+        self._win.width = self.width
+        self._win.height = self.height
+
+    def _set_clear_color(self, _=None):
+        pyglet.gl.glClearColor(*self.style.background_color.as_floats())
+
+    def _setup_window(self):
+        win = pyglet.window.Window(width=self.width, height=self.height)
+        win.on_draw = self.draw
+        self._set_clear_color()
+        self._win = win
+        self.SizeChanged.connect(self._update_window_size)
+        self.StyleChanged.connect(self._set_clear_color)
+
+    def _teardown_window(self):
+        self._win.close()
+        self.SizeChanged.disconnect(self._update_window_size)
+        self.StyleChanged.disconnect(self._set_clear_color)
+
+    def _draw(self):
+        self._win.clear()
+

ttgx/nsgui/__init__.py

Empty file removed.

ttgx/nsgui/button.py

-import ttgx
-from ttgx.nsgui.widget import AbstractWidget
-from ttgx.util import event
-
-
-@event.source
-class PushButton(AbstractWidget):
-    RESOURCES = ('background', 'label')
-    EVENTS = ('RegenBackground', 'RegenLabel')
-
-    def __init__(self, *args, x, y, width, height, text = ''):
-        super().__init__(*args, x=x, y=y, width=width, height=height)
-        self.text = text
-
-    def _propagate_background_size(self, _):
-        self._background.height = self.height
-        self._background.width = self.width
-
-    def _propagate_background_pos(self, _):
-        self._background.PositionChanged.trigger()
-
-    def _propagate_label_size(self, _):
-        self._label.height = self.height
-        self._label.width = self.width
-
-    def _propagate_label_pos(self, _):
-        self._label.PositionChanged.trigger()
-
-    def _setup_background(self):
-        self._background = ttgx.nsgui.frame.Frame(
-            self, self.style, x=0, y=0, width=self.width, height=self.height
-        )
-        self.SizeChanged.connect(self._propagate_background_size)
-        self.PositionChanged.connect(self._propagate_background_pos)
-        self._background.Clicked.implies(self.Clicked)
-
-    def _teardown_background(self):
-        self.SizeChanged.disconnect(self._propagate_background_size)
-        self.PositionChanged.disconnect(self._propagate_background_pos)
-
-    def _setup_label(self):
-        self._label = ttgx.nsgui.label.Label(
-            self._background, self.style, x=0, y=0,
-            width=self.width, height=self.height, text=self.text
-        )
-        self.SizeChanged.connect(self._propagate_label_size)
-        self.PositionChanged.connect(self._propagate_label_pos)
-        self._label.Clicked.implies(self.Clicked)
-
-    def _teardown_label(self):
-        self.SizeChanged.disconnect(self._propagate_label_size)
-        self.PositionChanged.disconnect(self._propagate_label_pos)
-
-    def _draw(self):
-        pass
-

ttgx/nsgui/driver.py

-import pyglet
-from ttgx.util import event
-from ttgx.nsgui.widget import AbstractWidget
-from ttgx.nsgui.util import widget_at
-
-
-class GUIDriver:
-    def __init__(self, window):
-        self._window = window
-        pwin = window.pyglet_window_object
-        self._setup_translators(pwin)
-        self._screens = {}
-        self._current_screen = None
-        self._current_widget = None
-
-    def add_screen(self, name, widget):
-        self._screens[name] = widget
-
-    def switch(self, name):
-        if name not in self._screens:
-            raise ValueError("No screen called " + repr(name))
-        if self._current_widget is not None:
-            self._current_widget.teardown()
-            self._window.remove_child(self._current_widget)
-        new_widget = self._screens[name]
-        new_widget.setup()
-        self._current_screen = name
-        self._current_widget = new_widget
-        self._window.add_child(new_widget)
-
-    @property