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