1#!/usr/bin/env python3
2
3"""
4Top-level widgets that manages the interface between pyglet and glooey.
5"""
6
7import pyglet
8import vecrec
9import autoprop
10
11from vecrec import Vector, Rect
12from glooey.widget import Widget
13from glooey.containers import Bin, Stack
14from glooey.helpers import *
15
16@autoprop
17class Root(Stack):
18    custom_one_child_gets_mouse = True
19
20    def __init__(self, window, batch=None, group=None):
21        super().__init__()
22
23        self.__window = window
24        self.__batch = batch or pyglet.graphics.Batch()
25        self._regroup(group)
26        self.__spurious_leave_event = False
27
28        window.push_handlers(self)
29
30    def on_mouse_enter(self, x, y):
31        # For some reason, whenever the mouse is clicked, X11 generates a
32        # on_mouse_leave event followed by a on_mouse_enter event.  There's no
33        # way to tell whether or not that happened in this handler alone, so we
34        # check a flag that would be set in on_mouse_leave() if a spurious
35        # event was detected.  If the event is spurious, reset the flag, ignore
36        # the event, and stop it from propagating.
37
38        if self.__spurious_leave_event:
39            self.__spurious_leave_event = False
40            return True
41        else:
42            super().on_mouse_enter(x, y)
43
44    def on_mouse_leave(self, x, y):
45        # For some reason, whenever the mouse is clicked, X11 generates a
46        # on_mouse_leave event followed by a on_mouse_enter event.  We can tell
47        # that this is happening in this handler because the mouse coordinates
48        # will still be under the widget.  In this case, set a flag so
49        # on_mouse_enter() will know to ignore the spurious event to follow and
50        # to stop it from propagating.
51
52        if self.is_under_mouse(x, y):
53            self.__spurious_leave_event = True
54            return True
55        else:
56            super().on_mouse_leave(x, y)
57
58    def get_root(self):
59        return self
60
61    def get_parent(self):
62        return self
63
64    def get_window(self):
65        return self.__window
66
67    def get_batch(self):
68        return self.__batch
69
70    def get_territory(self):
71        raise NotImplementedError
72
73    @update_function
74    def _repack(self):
75        self._claim()
76
77        too_narrow = self.territory.width < self.claimed_width
78        too_short = self.territory.height < self.claimed_height
79
80        # Complain if the root widget needs more space than it claimed.
81        if too_narrow or too_short:
82            message = "{} is only {}x{}, but its children are {}x{}."
83            raise RuntimeError(
84                    message.format(
85                        self,
86                        self.territory.width, self.territory.height,
87                        self.claimed_width, self.claimed_height,
88            ))
89
90        self._resize(self.territory)
91
92    def _regroup(self, group):
93        # We need to replace None with an actual group, because glooey
94        # interprets None as "my parent hasn't given me a group yet."
95        super()._regroup(group or pyglet.graphics.Group())
96
97
98@autoprop
99class Gui(Root):
100    custom_clear_before_draw = True
101
102    def __init__(self, window, *, cursor=None, hotspot=None,
103            clear_before_draw=None, batch=None, group=None):
104
105        super().__init__(window, batch, group)
106
107        # Set the cursor, if the necessary arguments were given.
108        if cursor and not hotspot:
109            raise ValueError("Specified cursor but not hotspot.")
110        if hotspot and not cursor:
111            raise ValueError("Specified hotspot but not cursor.")
112        if cursor and hotspot:
113            self.set_cursor(cursor, hotspot)
114
115        # Disable clearing the window on each draw.
116        self.clear_before_draw = first_not_none((
117            clear_before_draw, self.custom_clear_before_draw))
118
119    def on_draw(self):
120        if self.clear_before_draw:
121            self.window.clear()
122        self.batch.draw()
123
124    def on_resize(self, width, height):
125        # Make sure the window actually changed size before starting a repack.
126        # We compare against `self.rect` (which requires subtracting the
127        # padding from the given width and height) instead of `self.territory`
128        # so that changing the padding triggers a repack like it should.
129        width -= self.left_padding + self.right_padding
130        height -= self.top_padding + self.bottom_padding
131        if self.rect and self.rect.size != (width, height):
132            self._repack()
133
134    def get_territory(self):
135        return Rect.from_pyglet_window(self.window)
136
137    def set_cursor(self, image, hot_spot):
138        hx, hy = Vector.from_anything(hot_spot)
139        cursor = pyglet.window.ImageMouseCursor(image, hx, hy)
140        self.window.set_mouse_cursor(cursor)
141
142
143@autoprop
144@register_event_type('on_mouse_pan')
145class PanningGui(Gui):
146    """
147    A window that emits ``on_mouse_pan`` events when the mouse is off-screen.
148
149    `PanningGui` is typically used in conjunction with `Viewport`, which is a
150    container that scrolls its children in response to ``on_mouse_pan`` events.
151    Together, these two widgets allow the user to scroll widgets (e.g. a game
152    map) by moving the mouse off the screen, which is a common motif in games.
153
154    The ``on_mouse_pan`` event fires continuously (e.g. 60 Hz) when the mouse
155    is off the screen.  Each event specifies how far off the screen the mouse
156    is in the vertical and horizontal dimensions, and how much time elapsed
157    since the last event fired.
158
159    Normally, a window doesn't receive information about the mouse unless the
160    mouse is within the boundaries of that window.  In order for `PanningGui`
161    to break this rule and keep track of the mouse while it is outside the
162    window, it has to enable `mouse exclusivity`__.  One consequence of this is
163    that, unlike with `Gui`, a cursor image must be provided.
164
165    __ https://pyglet.readthedocs.io/en/pyglet-1.2-maintenance/programming_guide/mouse.html#mouse-exclusivity
166    """
167
168    def __init__(self, window, cursor, hotspot, *, batch=None, group=None):
169        mouse_group = pyglet.graphics.OrderedGroup(1, parent=group)
170        gui_group = pyglet.graphics.OrderedGroup(0, parent=group)
171
172        super().__init__(window, batch=batch, group=gui_group)
173        window.set_exclusive_mouse(True)
174
175        # Where the mouse is.  Because mouse exclusivity is enabled, we have to
176        # keep track of this ourselves.
177        self.mouse = self.territory.center
178
179        # Where the mouse would be, if it wasn't confined to the window.  The
180        # difference between `self.mouse` and `self.shadow_mouse` gives the
181        # direction of each ``on_mouse_pan`` event that gets fired.
182        self.shadow_mouse = None
183
184        # Because mouse exclusivity is enabled, we have to provide an image for
185        # the cursor.
186        hotspot = vecrec.cast_anything_to_vector(hotspot)
187        cursor.anchor_x = hotspot.x
188        cursor.anchor_y = hotspot.y
189
190        self.cursor = pyglet.sprite.Sprite(
191                cursor, batch=self.batch, group=mouse_group)
192
193    def do_resize(self):
194        self.mouse = self.territory.center
195
196    def do_draw(self):
197        self.cursor.visible = True
198        self.cursor.position = self.mouse.tuple
199
200    def do_undraw(self):
201        self.cursor.visible = False
202
203    def on_mouse_press(self, x, y, button, modifiers):
204        super().on_mouse_press(self.mouse.x, self.mouse.y, button, modifiers)
205
206    def on_mouse_release(self, x, y, button, modifiers):
207        super().on_mouse_release(self.mouse.x, self.mouse.y, button, modifiers)
208
209    def on_mouse_motion(self, x, y, dx, dy):
210        self._update_mouse(dx, dy)
211        super().on_mouse_motion(self.mouse.x, self.mouse.y, dx, dy)
212
213    def on_mouse_enter(self, x, y):
214        # The mouse never really enters or exits a window with mouse
215        # exclusivity enabled, so mouse_enter and mouse_exits events should
216        # never get triggered.  However, there is at least one scenario where
217        # pyglet will do just that.
218        #
219        # This scenario can be triggered by instantly moving the window by more
220        # than half of its width or height.  The issue is that, with mouse
221        # exclusivity enabled, pyglet keeps the mouse in the center of the
222        # screen.  If the window is moved far enough in one frame to put the
223        # old mouse position outside the window, a spurious set of mouse_exit
224        # and mouse_enter events get triggered.
225        #
226        # The solution to this problem is simply to ignore mouse_enter and
227        # mouse_exit events for PanningGui objects.
228        return True
229
230    def on_mouse_leave(self, x, y):
231        # See comment in on_mouse_enter().
232        return True
233
234    def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers):
235        self._update_mouse(dx, dy)
236        super().on_mouse_drag(self.mouse.x, self.mouse.y, dx, dy, buttons, modifiers)
237
238    def on_mouse_drag_enter(self, x, y):
239        super().on_mouse_drag_enter(self.mouse.x, self.mouse.y)
240
241    def on_mouse_drag_leave(self, x, y):
242        super().on_mouse_drag_leave(self.mouse.x, self.mouse.y)
243
244    def on_mouse_scroll(self, x, y, scroll_x, scroll_y):
245        super().on_mouse_scroll(self.mouse.x, self.mouse.y, scroll_x, scroll_y)
246
247    def _update_mouse(self, dx, dy):
248        self.mouse += (dx, dy)
249
250        # Decide whether or to start or stop panning.
251
252        if self.mouse not in self.territory:
253            if self.shadow_mouse is None:
254                self._start_mouse_pan()
255
256        if self.shadow_mouse is not None:
257            if self.mouse in self.territory.get_shrunk(5):
258                self._stop_mouse_pan()
259
260        # Move the shadow mouse.
261
262        if self.shadow_mouse is not None:
263            self.shadow_mouse += (dx, dy)
264
265            if self.territory.left < self.mouse.x < self.territory.right:
266                self.shadow_mouse.x = self.mouse.x
267            if self.territory.bottom < self.mouse.y < self.territory.top:
268                self.shadow_mouse.y = self.mouse.y
269
270        # Keep the mouse on screen.  It feels like there should be a function
271        # for this in vecrec...
272
273        if self.mouse.x < self.territory.left:
274            self.mouse.x = self.territory.left
275        if self.mouse.x > self.territory.right:
276            self.mouse.x = self.territory.right
277        if self.mouse.y < self.territory.bottom:
278            self.mouse.y = self.territory.bottom
279        if self.mouse.y > self.territory.top:
280            self.mouse.y = self.territory.top
281
282        # Update the mouse sprite.
283
284        self._draw()
285
286    def _start_mouse_pan(self):
287        self.shadow_mouse = self.mouse.copy()
288        pyglet.clock.schedule_interval(self._update_mouse_pan, 1/60)
289
290    def _stop_mouse_pan(self):
291        self.shadow_mouse = None
292        pyglet.clock.unschedule(self._update_mouse_pan)
293
294    def _update_mouse_pan(self, dt):
295        direction = self.shadow_mouse - self.mouse
296        self.dispatch_event('on_mouse_pan', direction, dt)
297
298