diff --git a/doc/builtins.rst b/doc/builtins.rst index 4ac0e0fd..642d7320 100644 --- a/doc/builtins.rst +++ b/doc/builtins.rst @@ -374,6 +374,155 @@ can use the :func:`on_music_end() hook ` to do something when the music ends - for example, to pick another track at random. +.. _mouse: + +Mouse +----- + +The built-in mouse object can be used to get information of various current +states of the mouse in the game: where it is, what buttons are pressed, +whether it is visible and many more... + +It can also be used to manipulate the mouse, for example changing its position. + + +.. class:: Mouse + + .. attribute:: pressed + + Returns a tuple of three booleans for the left, middle and right + mouse buttons in order. True means the button is currently pressed. + + .. attribute:: pressed_left + + Returns True if the left mouse button is currently pressed, + False else. + + .. attribute:: pressed_middle + + Returns True if the middle mouse button is currently pressed, + False else. + + .. attribute:: pressed_right + + Returns True if the right mouse button is currently pressed, + False else. + + .. attribute:: pos + + Returns the current position of the mouse. + + Setting this will teleport the mouse to the supplied position + inside the game window. + + .. attribute:: last_called_pos + + Returns the position of the mouse at the time this value + was last read. + + .. attribute:: recent_pos + + Returns a tuple of the n last positions the mouse had moved + to starting from the most recent. Default for n is 60 and + can be changed via recent_pos_max. + + .. attribute:: recent_pos_max + + Returns the total number of positions tracked via recent_pos. + + Increasing this value simply extends the length of the queue + of tracked positions. Decreasing it also cuts the queue to the + new maximum number of elements. + + .. attribute:: rel + + Returns the relative position change of the mouse over the last + frame. + + .. attribute:: last_called_rel + + Returns the relative position change of the mouse since this + value was last read. + + .. attribute:: recent_rel + + Returns a tuple of the n last position changes the mouse had, + starting from the most recent. Default for n is 60 and + can be changed via recent_rel_max. + + .. attribute:: recent_rel_max + + Returns the total number of position changed tracked via + recent_pos. + + Increasing this value simply extends the length of the queue + of tracked positions. Decreasing it also cuts the queue to the + new maximum number of elements. + + .. attribute:: visible + + Returns a boolean of whether the mouse cursor is currently + visible. + + Setting this to either a boolean or 0 or 1 makes the mouse + visible or invisible. + + .. attribute:: focused + + Returns a boolean of whether the game window is currently + focused. + + +Recent pos and rel +'''''''''''''''''' + +The ``mouse.recent_pos`` and ``mouse.recent_rel`` attributes allow +you to get the last recorded positions and relative movements from +any mouse movement event. This can be used among other things to +make visual effects of things "following" the mouse. Here is an +example that draws a trail of circles behind the mouse:: + + def draw(): + for p in mouse.recent_pos: + screen.draw.circle(p, 5, "red") + +How many events back are recorded is controlled by ``recent_pos_max`` +and ``recent_rel_max``. It's important to note here that these +attributes don't record the position and relative movement for each +frame, like ``mouse.rel`` for example does, but rather the positions +and changes for each individual mouse movement event. Since multiple +mouse movement events can happen in a single frame, there is no fixed +relationship between the number of frames passed and the recorded +positions and relative changes. In general though, a higher maximum +value means positions and changes from longer before will still be +recorded. + + +LEFT or pressed_left +'''''''''''''''''''' + +While both are accessed via ``mouse``, ``mouse.LEFT`` and +``mouse.pressed_left`` are different things. ``mouse.LEFT`` is used +in your custom functions that handle mouse events, e.g. +``if button == mouse.LEFT`` whereas ``mouse.pressed`` and its variants +can be used outside of these, for example in the ``update()`` function to +check whether a mouse button is pressed at that moment. + +Example:: + + def on_mouse_down(button): + if button == mouse.LEFT: + play_tune() + + def update(): + if mouse.pressed_right: + fireworks() + + +``mouse.WHEELUP`` and ``mouse.WHEELDOWN`` have no equivalent outside of +``on_mouse`` functions since these can't be held down. + + .. _clock: Clock diff --git a/src/pgzero/builtins.py b/src/pgzero/builtins.py index 6d6e54b0..b90b420e 100644 --- a/src/pgzero/builtins.py +++ b/src/pgzero/builtins.py @@ -8,11 +8,14 @@ from .animation import animate from .rect import Rect, ZRect from .loaders import images, sounds -from .constants import mouse, keys, keymods +# Removed the former mouse enum import +from .constants import keys, keymods from .game import exit # The actual screen will be installed here from .screen import screen_instance as screen +# Make mouse globally available +from .mouse import mouse_instance as mouse __all__ = [ diff --git a/src/pgzero/game.py b/src/pgzero/game.py index 4f242a85..7eeef6c1 100644 --- a/src/pgzero/game.py +++ b/src/pgzero/game.py @@ -9,7 +9,6 @@ import pgzero.keyboard import pgzero.screen import pgzero.loaders -import pgzero.screen from . import constants @@ -63,6 +62,7 @@ def __init__( self.icon = None self.fps = fps self.keyboard = pgzero.keyboard.keyboard + self.mouse = pgzero.mouse.mouse_instance self.handlers = {} def reinit_screen(self) -> bool: @@ -251,6 +251,9 @@ def inject_global_handlers(self): user_key_down = self.handlers.get(pygame.KEYDOWN) user_key_up = self.handlers.get(pygame.KEYUP) + user_mouse_down = self.handlers.get(pygame.MOUSEBUTTONDOWN) + user_mouse_up = self.handlers.get(pygame.MOUSEBUTTONUP) + user_mouse_move = self.handlers.get(pygame.MOUSEMOTION) def key_down(event): if event.key == pygame.K_q and \ @@ -265,8 +268,27 @@ def key_up(event): if user_key_up: return user_key_up(event) + def mouse_down(event): + self.mouse._press(event.button) + if user_mouse_down: + return user_mouse_down(event) + + def mouse_up(event): + self.mouse._release(event.button) + if user_mouse_up: + return user_mouse_up(event) + + def mouse_move(event): + self.mouse._set_pos(event.pos) + self.mouse._add_rel(event.rel) + if user_mouse_move: + return user_mouse_move(event) + self.handlers[pygame.KEYDOWN] = key_down self.handlers[pygame.KEYUP] = key_up + self.handlers[pygame.MOUSEBUTTONDOWN] = mouse_down + self.handlers[pygame.MOUSEBUTTONUP] = mouse_up + self.handlers[pygame.MOUSEMOTION] = mouse_move def handle_events(self, dt, update) -> bool: """Handle all events for the current frame. @@ -275,6 +297,12 @@ def handle_events(self, dt, update) -> bool: """ updated = False + # If no mouse movement occured, the rel state of the + # mouse is set to (0, 0). This is necessary since it + # otherwise retains the rel of the last movement + # even if the mouse hasn't been moved. + self.mouse._null_rel() + for event in pygame.event.get(): handler = self.handlers.get(event.type) if handler: diff --git a/src/pgzero/mouse.py b/src/pgzero/mouse.py new file mode 100644 index 00000000..b317b7c8 --- /dev/null +++ b/src/pgzero/mouse.py @@ -0,0 +1,182 @@ +import pygame +import pygame.mouse +from collections import deque +from itertools import islice + + +class Mouse: + """Interface to the pygame mouse. Also integrates former + enum properties of mouse to retain all previous functionality + and be backwards compatible. + """ + + def __init__(self): + self._pressed = [False, False, False] + self._pos = None + self._rel = (0, 0) + self._last_pos = (0, 0) + self._last_rel = (0, 0) + self._recent_pos = deque(maxlen=60) + self._recent_rel = deque(maxlen=60) + + def _press(self, button): + self._pressed[button - 1] = True + + def _release(self, button): + self._pressed[button - 1] = False + + def _set_pos(self, pos): + self._pos = pos + self._recent_pos.appendleft(pos) + + def _add_rel(self, rel): + self._rel = self._rel[0] + rel[0], self._rel[1] + rel[1] + lrx = self._last_rel[0] + rel[0] + lry = self._last_rel[1] + rel[1] + self._last_rel = lrx, lry + self._recent_rel.appendleft(rel) + + def _null_rel(self): + self._rel = (0, 0) + + @property + def LEFT(self): + return 1 + + @property + def MIDDLE(self): + return 2 + + @property + def RIGHT(self): + return 3 + + @property + def WHEEL_UP(self): + return 4 + + @property + def WHEEL_DOWN(self): + return 5 + + # TODO: Clean up the return value of this to make it easier + # to understand for users. + @property + def pressed(self): + return tuple(self._pressed) + + @property + def pressed_left(self): + return self._pressed[0] + + @property + def pressed_middle(self): + return self._pressed[1] + + @property + def pressed_right(self): + return self._pressed[2] + + @property + def pos(self): + return self._pos + + @pos.setter + def pos(self, pos, pos_y=None): + # Setting mouse visibility is a workaround to allow + # setting the mouse position under Wayland, which + # normally prevents user applications from doing this. + # switch_back is necessary in case the pointer is + # moved while already set invisible by the user. + switch_back = True if self.visible else False + pygame.mouse.set_visible(False) + if pos_y: + pygame.mouse.set_pos([pos, pos_y]) + # TODO: Nicer way to do this? Does it have to be so strict + # in checking? + elif isinstance(pos, (tuple, list)) and len(pos) == 2 and\ + isinstance(pos[0], int) and isinstance(pos[1], int): + pygame.mouse.set_pos(pos) + else: + raise ValueError("Setting the mouse position requires either" + " one tuple with two integers or two integers" + " as individual parameters.") + if switch_back: + pygame.mouse.set_visible(True) + # Note: Setting self._pos manually here isn't necessary + # because set_pos() triggers a new MOUSEMOTION event. + + @property + def last_called_pos(self): + p = self._last_pos + self._last_pos = self._pos + return p + + @property + def recent_pos(self): + return tuple(self._recent_pos) + + @property + def recent_pos_max(self): + return self._recent_pos.maxlen + + @recent_pos_max.setter + def recent_pos_max(self, maxl): + c_max = self._recent_pos.maxlen + if maxl > c_max: + self._recent_pos = deque(self._recent_pos, maxlen=maxl) + elif maxl < c_max: + elems = islice(self._recent_pos, maxl) + self._recent_pos = deque(elems, maxlen=maxl) + + @property + def rel(self): + return self._rel + + @property + def last_called_rel(self): + r = self._last_rel + self._last_rel = (0, 0) + return r + + @property + def recent_rel(self): + return tuple(self._recent_rel) + + @property + def recent_rel_max(self): + return self._recent_rel.maxlen + + @recent_rel_max.setter + def recent_rel_max(self, maxl): + c_max = self._recent_rel.maxlen + if maxl > c_max: + self._recent_rel = deque(self._recent_rel, maxlen=maxl) + elif maxl < c_max: + elems = islice(self._recent_rel, maxl) + self._recent_rel = deque(elems, maxlen=maxl) + + @property + def visible(self): + return pygame.mouse.get_visible() + + @visible.setter + def visible(self, val): + match val: + case bool(): + vis = val + case 0: + vis = False + case 1: + vis = True + case _: + raise ValueError("Value to set mouse visibility must be" + " a boolean or either 1 or 0.") + pygame.mouse.set_visible(vis) + + @property + def focused(self): + return pygame.mouse.get_focused() + + +mouse_instance = Mouse() diff --git a/test/test_mouse.py b/test/test_mouse.py new file mode 100644 index 00000000..ec996eef --- /dev/null +++ b/test/test_mouse.py @@ -0,0 +1,142 @@ +import unittest +from collections import deque + +import pygame + +from pgzero.loaders import set_root +from pgzero.mouse import mouse_instance as mouse + + +class MouseTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + """Initialise the display and set loaders to target the current dir.""" + pygame.init() + cls.surf = pygame.display.set_mode((200, 200)) + set_root(__file__) + + @classmethod + def tearDownClass(cls): + """Shut down the display.""" + pygame.display.quit() + + def setUp(self): + mouse._press(mouse.LEFT) + mouse._press(mouse.MIDDLE) + mouse._set_pos((10, 10)) + pygame.mouse.set_visible(False) + + def tearDown(self): + mouse._null_rel() + + def test_never_pressed(self): + """A button value is false if never pressed.""" + self.assertFalse(mouse.pressed_right) + + def test_press(self): + """We can check for depressed buttons by property check.""" + self.assertTrue(mouse.pressed_left) + + def test_release(self): + """We can release a previously pressed key.""" + mouse._release(mouse.LEFT) + self.assertFalse(mouse.pressed_left) + + def test_get_pressed(self): + """We can get all button states together.""" + self.assertEqual(mouse.pressed, (True, True, False)) + + def test_already_pressed(self): + """Pressing an already pressed button still works.""" + mouse._press(mouse.LEFT) + self.assertTrue(mouse.pressed_left) + + def test_already_released(self): + """Releasing an unpressed button still works.""" + mouse._release(mouse.RIGHT) + self.assertFalse(mouse.pressed_right) + + def test_release_other(self): + """Releasing one key does not release any others that are pressed.""" + mouse._release(mouse.LEFT) + self.assertTrue(mouse.pressed_middle) + + def test_uppercase_constants(self): + """The uppercase attribute names from earlier in the project still + work. This is important for backwards compatibility.""" + self.assertEqual(mouse.LEFT, 1) + self.assertEqual(mouse.MIDDLE, 2) + self.assertEqual(mouse.RIGHT, 3) + self.assertEqual(mouse.WHEEL_UP, 4) + self.assertEqual(mouse.WHEEL_DOWN, 5) + + def test_get_position(self): + """We get the correct current position of the mouse.""" + self.assertEqual(mouse.pos, (10, 10)) + + def test_get_lc_position(self): + """We can get the last called position.""" + self.assertEqual(mouse.last_called_pos, (0, 0)) + + def test_set_position(self): + """We can change the mouse position.""" + # TODO: Can this be done via mouse.pos = (25, 25)? Problem is that a + # normal Pygame event would have to be gotten before changes take + # effect. + mouse._set_pos((25, 25)) + self.assertEqual(mouse.pos, (25, 25)) + + def test_recent_pos(self): + """Recent positions can be gotten.""" + mouse._recent_pos = deque(maxlen=60) + # TODO: Can this be done via mouse.pos = (1, 1) etc.? Problem is that a + # normal Pygame event would have to be gotten before changes take + # effect. + mouse._set_pos((1, 1)) + mouse._set_pos((2, 2)) + self.assertEqual(mouse.recent_pos, ((2, 2), (1, 1))) + + def test_recent_pos_max(self): + """We can change the number of recent positions.""" + mouse.recent_pos_max = 120 + self.assertEqual(mouse._recent_pos.maxlen, 120) + + def test_get_relative(self): + """We can get the last position change, starting from (10,10).""" + # TODO: Can this be done via mouse.pos = (5, 5)? Problem is that a + # normal Pygame event would have to be gotten before changes take + # effect. + mouse._add_rel((-5, -5)) + self.assertEqual(mouse.rel, (-5, -5)) + + def test_get_lc_relative(self): + """We can get the last called position change, startig from (0, 0).""" + # TODO: Can this be done via mouse.pos = (25, 25)? Problem is that a + # normal Pygame event would have to be gotten before changes take + # effect. + mouse._add_rel((25, 25)) + self.assertEqual(mouse.last_called_rel, (25, 25)) + + def test_recent_rel(self): + """Recent position changes can be gotten.""" + mouse._recent_rel = deque(maxlen=60) + # TODO: Can this be done via mouse.pos = (1, 1) etc.? Problem is that a + # normal Pygame event would have to be gotten before changes take + # effect. + mouse._add_rel((2, 2)) + mouse._add_rel((-1, -1)) + self.assertEqual(mouse.recent_rel, ((-1, -1), (2, 2))) + + def test_recent_rel_max(self): + """We can change the number of recent position changes.""" + mouse.recent_rel_max = 120 + self.assertEqual(mouse._recent_rel.maxlen, 120) + + def test_get_visibility(self): + """We can get whether the mouse cursor is visible.""" + self.assertFalse(mouse.visible) + + def test_change_visibility(self): + """We can change mouse visibility.""" + mouse.visible = True + self.assertTrue(pygame.mouse.get_visible())