Repository: ppizarror/pygame-menu

Issue:
Customizing the KEY_APPLY variable
I would like to use either the Enter key or another key of my choice to select an option. Right now, from what I can tell, the only key allowed for this purpose is set in controls.py as `KEY_APPLY = __locals.K_RETURN`. Is there a way to customize the key used for this purpose?

Discussion:

Patch:
diff --git a/docs/_source/advanced_controller.rst b/docs/_source/advanced_controller.rst
new file mode 100644
index 00000000..e894e2ef
--- /dev/null
+++ b/docs/_source/advanced_controller.rst
@@ -0,0 +1,70 @@
+
+====================
+Configure controller
+====================
+
+The pygame-menu driver controller is very simple, and can be modified in two
+different ways.
+
+First, in ``pygame_menu.controls`` there are different constants to modify each
+key to different events, such as apply on a widget, scroll left or right, among
+others. If any constant is modified it will apply to the whole library.
+
+   ===============================================   ===========================
+   Event                                             Description
+   ===============================================   ===========================
+   :py:data:`pygame_menu.controls.KEY_APPLY`         Apply on widget
+   :py:data:`pygame_menu.controls.KEY_BACK`          Move back on menu or widget
+   :py:data:`pygame_menu.controls.KEY_CLOSE_MENU`    Close menu
+   :py:data:`pygame_menu.controls.KEY_LEFT`          Move left
+   :py:data:`pygame_menu.controls.KEY_MOVE_DOWN`     Move down
+   :py:data:`pygame_menu.controls.KEY_MOVE_UP`       Move up
+   :py:data:`pygame_menu.controls.KEY_RIGHT`         Move right
+   :py:data:`pygame_menu.controls.KEY_TAB`           Apply tab
+   ===============================================   ===========================
+
+   Note: See ``pygame_menu.controls`` module for more information.
+
+The following example changes the return key from ``RETURN`` to ``A``:
+
+.. code-block:: python
+
+    import pygame_menu.controls as ctrl
+    ctrl.KEY_APPLY = pygame.K_a
+
+    ...
+    menu.add.button('button', lambda: print('Clicked!'))
+
+As you might have seen, this procedure to change events is very simple, but limited.
+For such reason, pygame-menu provides another way to change the controls, by using
+the ``Controller`` object.
+
+This class can be passed to widgets or menu by calling ``object.set_controller(controller)``.
+Controller class defines several functions that can be overriden, or you can create
+a subclass using inheritance. For example, the following example changes ``apply``
+event to keys ``A``, ``B`` or ``C``, and additionally this changes the menu background
+color on demand. Methods must return ``True`` if satisfy the event condition.
+
+.. code-block:: python
+
+    from pygame_menu.controls import Controller
+    from random import randrange
+
+    # Create a controller
+    custom_controller = Controller()
+
+    def btn_apply(event, menu_object):
+        applied = event.key in (pygame.K_a, pygame.K_b, pygame.K_c)
+        if applied:
+            random_color = (randrange(0, 255), randrange(0, 255), randrange(0, 255))
+            menu_object.get_scrollarea().update_area_color(random_color)
+        return applied
+
+    custom_controller.apply = btn_apply
+
+    ...
+    button = menu.add.button('My button', lambda: print('Clicked!'))
+    button.set_controller(custom_controller) # Pass new controller to object
+
+.. autoclass:: pygame_menu.controls.Controller
+    :members:
\ No newline at end of file
diff --git a/docs/index.rst b/docs/index.rst
index d6d564c1..731c22ff 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -99,6 +99,7 @@ This chapter define rules and advanced tips and tricks to develop extensions for
     :caption: Advanced usage
 
     _source/advanced
+    _source/advanced_controller
     _source/advanced_selection
 
 .. toctree::
diff --git a/pygame_menu/_scrollarea.py b/pygame_menu/_scrollarea.py
index b5cf6c34..6448ab27 100644
--- a/pygame_menu/_scrollarea.py
+++ b/pygame_menu/_scrollarea.py
@@ -335,7 +335,22 @@ class ScrollArea(Base):
             else:
                 self._bg_surface.fill(assert_color(self._area_color))
 
-    def set_parent_scrollarea(self, parent: Optional['ScrollArea']) -> None:
+    def update_area_color(
+            self,
+            color: Optional[Union[ColorInputType, 'pygame_menu.BaseImage']]
+    ) -> 'ScrollArea':
+        """
+        Updates area color.
+
+        :param color: Color
+        :return: Self reference
+        """
+        self._area_color = color
+        self._bg_surface = None
+        self._make_background_surface()
+        return self
+
+    def set_parent_scrollarea(self, parent: Optional['ScrollArea']) -> 'ScrollArea':
         """
         Set parent ScrollArea.
 
@@ -344,6 +359,7 @@ class ScrollArea(Base):
         assert isinstance(parent, (ScrollArea, type(None)))
         assert parent != self, 'parent scrollarea cannot be set as itself'
         self._parent_scrollarea = parent
+        return self
 
     def get_parent(self) -> Optional['ScrollArea']:
         """
@@ -1119,8 +1135,11 @@ class ScrollArea(Base):
                     parent = parent._parent_scrollarea
         return view_rect_absolute
 
-    def to_real_position(self, virtual: Union['pygame.Rect', Tuple2NumberType], visible: bool = False
-                         ) -> Union['pygame.Rect', Tuple2IntType]:
+    def to_real_position(
+            self,
+            virtual: Union['pygame.Rect', Tuple2NumberType],
+            visible: bool = False
+    ) -> Union['pygame.Rect', Tuple2IntType]:
         """
         Return the real position/Rect according to the ScrollArea origin of a
         position/Rect in the world surface reference.
diff --git a/pygame_menu/controls.py b/pygame_menu/controls.py
index 1ffeff0e..fcc56e67 100644
--- a/pygame_menu/controls.py
+++ b/pygame_menu/controls.py
@@ -28,12 +28,21 @@ __all__ = [
     'KEY_LEFT',
     'KEY_MOVE_DOWN',
     'KEY_MOVE_UP',
-    'KEY_RIGHT'
+    'KEY_RIGHT',
+
+    # Controller object
+    'Controller'
 
 ]
 
 # Imports
-import pygame.locals as __locals
+# noinspection PyUnresolvedReferences
+import pygame_menu  # lgtm [py/unused-import]
+import pygame.locals as _locals
+from pygame.event import Event as EventType
+from typing import Union
+
+WidgetType = Union['pygame_menu.Menu', 'pygame_menu.widgets.Widget']
 
 # Joy pad
 JOY_AXIS_X = 0
@@ -49,11 +58,245 @@ JOY_RIGHT = (1, 0)
 JOY_UP = (0, 1)
 
 # Keyboard events
-KEY_APPLY = __locals.K_RETURN
-KEY_BACK = __locals.K_BACKSPACE
-KEY_CLOSE_MENU = __locals.K_ESCAPE
-KEY_LEFT = __locals.K_LEFT
-KEY_MOVE_DOWN = __locals.K_UP
-KEY_MOVE_UP = __locals.K_DOWN  # Consider keys are "inverted"
-KEY_RIGHT = __locals.K_RIGHT
-KEY_TAB = __locals.K_TAB
+KEY_APPLY = _locals.K_RETURN
+KEY_BACK = _locals.K_BACKSPACE
+KEY_CLOSE_MENU = _locals.K_ESCAPE
+KEY_LEFT = _locals.K_LEFT
+KEY_MOVE_DOWN = _locals.K_UP
+KEY_MOVE_UP = _locals.K_DOWN  # Consider keys are "inverted"
+KEY_RIGHT = _locals.K_RIGHT
+KEY_TAB = _locals.K_TAB
+
+
+# noinspection PyMethodMayBeStatic,PyUnusedLocal
+class Controller(object):
+    """
+    Controller class. Accepts any object and provides functions to handle each
+    event.
+    """
+    joy_delay: int
+    joy_repeat: int
+
+    def __init__(self) -> None:
+        self.joy_delay = JOY_DELAY
+        self.joy_repeat = JOY_REPEAT
+
+    def apply(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts apply key. Requires ``pygame.KEYDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.key == KEY_APPLY
+
+    def back(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts back key. Requires ``pygame.KEYDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.key == KEY_BACK
+
+    def close_menu(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts close menu key. Requires ``pygame.KEYDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.key == KEY_CLOSE_MENU
+
+    def delete(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts delete key. Requires ``pygame.KEYDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.key == _locals.K_DELETE
+
+    def end(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts end key. Requires ``pygame.KEYDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.key == _locals.K_END
+
+    def escape(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts escape key. Requires ``pygame.KEYDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.key == _locals.K_ESCAPE
+
+    def home(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts home key. Requires ``pygame.KEYDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.key == _locals.K_HOME
+
+    def joy_axis_x_left(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts joy movement on x-axis (left direction). Requires ``pygame.JOYAXISMOTION``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.axis == JOY_AXIS_X and event.value < -JOY_DEADZONE
+
+    def joy_axis_x_right(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts joy movement on x-axis (right direction). Requires ``pygame.JOYAXISMOTION``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.axis == JOY_AXIS_X and event.value > JOY_DEADZONE
+
+    def joy_axis_y_down(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts joy movement on y-axis (down direction). Requires ``pygame.JOYAXISMOTION``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.axis == JOY_AXIS_Y and event.value > JOY_DEADZONE
+
+    def joy_axis_y_up(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts joy movement on y-axis (up direction). Requires ``pygame.JOYAXISMOTION``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.axis == JOY_AXIS_Y and event.value < -JOY_DEADZONE
+
+    def joy_back(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts joy back button. Requires ``pygame.JOYBUTTONDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.button == JOY_BUTTON_BACK
+
+    def joy_down(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts joy movement to down direction. Requires ``pygame.JOYHATMOTION``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.value == JOY_DOWN
+
+    def joy_left(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts joy movement to left direction. Requires ``pygame.JOYHATMOTION``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.value == JOY_LEFT
+
+    def joy_right(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts joy movement to right direction. Requires ``pygame.JOYHATMOTION``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.value == JOY_RIGHT
+
+    def joy_select(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts joy select button. Requires ``pygame.JOYBUTTONDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.button == JOY_BUTTON_SELECT
+
+    def joy_up(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts joy movement to up direction. Requires ``pygame.JOYHATMOTION``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.value == JOY_UP
+
+    def left(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts left key. Requires ``pygame.KEYDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.key == KEY_LEFT
+
+    def move_down(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts move down key. Requires ``pygame.KEYDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.key == KEY_MOVE_DOWN
+
+    def move_up(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts move up key. Requires ``pygame.KEYDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.key == KEY_MOVE_UP
+
+    def right(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts right key. Requires ``pygame.KEYDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.key == KEY_RIGHT
+
+    def tab(self, event: EventType, widget: WidgetType) -> bool:
+        """
+        Accepts tab key. Requires ``pygame.KEYDOWN``.
+
+        :param event: Event
+        :param widget: Widget that accepts the event
+        :return: True if event matches
+        """
+        return event.key == KEY_TAB
diff --git a/pygame_menu/menu.py b/pygame_menu/menu.py
index 2de863f7..1013b723 100644
--- a/pygame_menu/menu.py
+++ b/pygame_menu/menu.py
@@ -16,12 +16,12 @@ import time
 
 import pygame
 import pygame.gfxdraw as gfxdraw
-import pygame_menu.controls as ctrl
 import pygame_menu.events as _events
 
 from pygame_menu._base import Base
 from pygame_menu._decorator import Decorator
 from pygame_menu._widgetmanager import WidgetManager
+from pygame_menu.controls import Controller
 from pygame_menu.locals import ALIGN_CENTER, ALIGN_LEFT, ALIGN_RIGHT, \
     ORIENTATION_HORIZONTAL, ORIENTATION_VERTICAL, FINGERDOWN, FINGERUP, FINGERMOTION
 from pygame_menu._scrollarea import ScrollArea, get_scrollbars_from_position
@@ -109,10 +109,11 @@ class Menu(Base):
     _column_pos_x: List[NumberType]
     _column_widths: List[NumberType]
     _columns: int
+    _ctrl: 'Controller'
     _current: 'Menu'
     _decorator: 'Decorator'
-    _disable_exit: bool
     _disable_draw: bool
+    _disable_exit: bool
     _disable_update: bool
     _enabled: bool
     _height: int
@@ -449,6 +450,9 @@ class Menu(Base):
             screen_dimension=screen_dimension
         )
 
+        # Setups controller
+        self._ctrl = Controller()
+
         # Init joystick
         self._joystick = joystick_enabled
         if self._joystick:
@@ -2113,6 +2117,22 @@ class Menu(Base):
             gfxdraw.filled_polygon(surface, coords[area], self._theme.focus_background_color)
         return coords
 
+    def set_controller(self, controller: 'Controller') -> 'Menu':
+        """
+        Set a new controller object.
+
+        .. note::
+
+            This is applied only to the base Menu (not the currently displayed,
+            stored in ``_current`` pointer); for such behaviour apply to
+            :py:meth:`pygame_menu.menu.Menu.get_current` object.
+
+        :param controller: Controller
+        :return: Self reference
+        """
+        self._ctrl = controller
+        return self
+
     def enable(self) -> 'Menu':
         """
         Enables Menu (can check events and draw).
@@ -2464,37 +2484,37 @@ class Menu(Base):
                     if self._keyboard_ignore_nonphysical and not check_key_pressed_valid(event):
                         continue
 
-                    if event.key == ctrl.KEY_MOVE_DOWN:
+                    if self._ctrl.move_down(event, self):
                         if self._current._down(apply_sound=True):
                             self._current._last_update_mode.append(_events.MENU_LAST_MOVE_DOWN)
                             updated = True
                             break
 
-                    elif event.key == ctrl.KEY_MOVE_UP:
+                    elif self._ctrl.move_up(event, self):
                         if self._current._up(apply_sound=True):
                             self._current._last_update_mode.append(_events.MENU_LAST_MOVE_UP)
                             updated = True
                             break
 
-                    elif event.key == ctrl.KEY_LEFT:
+                    elif self._ctrl.left(event, self):
                         if self._current._left(apply_sound=True):
                             self._current._last_update_mode.append(_events.MENU_LAST_MOVE_LEFT)
                             updated = True
                             break
 
-                    elif event.key == ctrl.KEY_RIGHT:
+                    elif self._ctrl.right(event, self):
                         if self._current._right(apply_sound=True):
                             self._current._last_update_mode.append(_events.MENU_LAST_MOVE_RIGHT)
                             updated = True
                             break
 
-                    elif event.key == ctrl.KEY_BACK and self._top._prev is not None:
+                    elif self._ctrl.back(event, self) and self._top._prev is not None:
                         self._current._sound.play_close_menu()
                         self.reset(1)  # public, do not use _current
                         self._current._last_update_mode.append(_events.MENU_LAST_MENU_BACK)
                         updated = True
 
-                    elif event.key == ctrl.KEY_CLOSE_MENU:
+                    elif self._ctrl.close_menu(event, self):
                         self._current._sound.play_close_menu()
                         if self._current._close():
                             self._current._last_update_mode.append(_events.MENU_LAST_MENU_CLOSE)
@@ -2502,25 +2522,25 @@ class Menu(Base):
 
                 # User moves hat joystick
                 elif event.type == pygame.JOYHATMOTION and self._current._joystick:
-                    if event.value == ctrl.JOY_UP:
+                    if self._ctrl.joy_up(event, self):
                         if self._current._down(apply_sound=True):
                             self._current._last_update_mode.append(_events.MENU_LAST_MOVE_DOWN)
                             updated = True
                             break
 
-                    elif event.value == ctrl.JOY_DOWN:
+                    elif self._ctrl.joy_down(event, self):
                         if self._current._up(apply_sound=True):
                             self._current._last_update_mode = _events.MENU_LAST_MOVE_UP
                             updated = True
                             break
 
-                    elif event.value == ctrl.JOY_LEFT:
+                    elif self._ctrl.joy_left(event, self):
                         if self._current._left(apply_sound=True):
                             self._current._last_update_mode = _events.MENU_LAST_MOVE_LEFT
                             updated = True
                             break
 
-                    elif event.value == ctrl.JOY_RIGHT:
+                    elif self._ctrl.joy_right(event, self):
                         if self._current._right(apply_sound=True):
                             self._current._last_update_mode = _events.MENU_LAST_MOVE_RIGHT
                             updated = True
@@ -2532,26 +2552,24 @@ class Menu(Base):
                     prev = self._current._joy_event
                     self._current._joy_event = 0
 
-                    if event.axis == ctrl.JOY_AXIS_Y and event.value < -ctrl.JOY_DEADZONE:
+                    if self._ctrl.joy_axis_y_up(event, self):
                         self._current._joy_event |= JOY_EVENT_UP
 
-                    elif event.axis == ctrl.JOY_AXIS_Y and event.value > ctrl.JOY_DEADZONE:
+                    elif self._ctrl.joy_axis_y_down(event, self):
                         self._current._joy_event |= JOY_EVENT_DOWN
 
-                    elif event.axis == ctrl.JOY_AXIS_X and event.value < -ctrl.JOY_DEADZONE and \
-                            self._current._used_columns > 1:
+                    elif self._ctrl.joy_axis_x_left(event, self) and self._current._used_columns > 1:
                         self._current._joy_event |= JOY_EVENT_LEFT
 
-                    elif event.axis == ctrl.JOY_AXIS_X and event.value > ctrl.JOY_DEADZONE and \
-                            self._current._used_columns > 1:
+                    elif self._ctrl.joy_axis_x_right(event, self) and self._current._used_columns > 1:
                         self._current._joy_event |= JOY_EVENT_RIGHT
 
                     if self._current._joy_event:
                         sel = self._current._handle_joy_event(True)
                         if self._current._joy_event == prev:
-                            pygame.time.set_timer(self._current._joy_event_repeat, ctrl.JOY_REPEAT)
+                            pygame.time.set_timer(self._current._joy_event_repeat, self._ctrl.joy_repeat)
                         else:
-                            pygame.time.set_timer(self._current._joy_event_repeat, ctrl.JOY_DELAY)
+                            pygame.time.set_timer(self._current._joy_event_repeat, self._ctrl.joy_delay)
                         if sel:
                             self._current._last_update_mode.append(_events.MENU_LAST_JOY_REPEAT)
                             updated = True
@@ -2563,7 +2581,7 @@ class Menu(Base):
                 elif event.type == self._current._joy_event_repeat:
                     if self._current._joy_event:
                         sel = self._current._handle_joy_event(True)
-                        pygame.time.set_timer(self._current._joy_event_repeat, ctrl.JOY_REPEAT)
+                        pygame.time.set_timer(self._current._joy_event_repeat, self._ctrl.joy_repeat)
                         if sel:
                             self._current._last_update_mode.append(_events.MENU_LAST_JOY_REPEAT)
                             updated = True
diff --git a/pygame_menu/version.py b/pygame_menu/version.py
index 5c186178..1319c588 100644
--- a/pygame_menu/version.py
+++ b/pygame_menu/version.py
@@ -34,6 +34,6 @@ class Version(tuple):
     patch = property(lambda self: self[2])
 
 
-vernum = Version(4, 2, 9)
+vernum = Version(4, 3, 0)
 ver = str(vernum)
 rev = ''
diff --git a/pygame_menu/widgets/core/widget.py b/pygame_menu/widgets/core/widget.py
index 3c2c6a59..ce2246e5 100644
--- a/pygame_menu/widgets/core/widget.py
+++ b/pygame_menu/widgets/core/widget.py
@@ -39,6 +39,7 @@ import pygame_menu
 
 from pygame_menu._base import Base
 from pygame_menu._decorator import Decorator
+from pygame_menu.controls import Controller
 from pygame_menu.font import FontType
 from pygame_menu.locals import POSITION_NORTHWEST, POSITION_SOUTHWEST, POSITION_WEST, \
     POSITION_EAST, POSITION_NORTHEAST, POSITION_CENTER, POSITION_NORTH, POSITION_SOUTH, \
@@ -232,6 +233,7 @@ class Widget(Base):
     _border_width: int
     _check_mouseleave_call_render: bool
     _col_row_index: Tuple3IntType
+    _ctrl: 'Controller'
     _cursor: CursorType
     _decorator: 'Decorator'
     _default_value: Any
@@ -442,6 +444,7 @@ class Widget(Base):
         self._selection_effect_draw_post = True
 
         # Inputs
+        self._ctrl = Controller()
         self._keyboard_enabled = True
         self._keyboard_ignore_nonphysical = True  # Ignores non-physical keyboard buttons pressed
         self._joystick_enabled = True
@@ -3122,6 +3125,16 @@ class Widget(Base):
         self._tab_size = tab_size
         return self
 
+    def set_controller(self, controller: 'Controller') -> 'Widget':
+        """
+        Set a new controller object.
+
+        :param controller: Controller
+        :return: Self reference
+        """
+        self._ctrl = controller
+        return self
+
 
 class _WidgetCopyException(Exception):
     """
diff --git a/pygame_menu/widgets/widget/button.py b/pygame_menu/widgets/widget/button.py
index 95fbcefb..60c353b7 100644
--- a/pygame_menu/widgets/widget/button.py
+++ b/pygame_menu/widgets/widget/button.py
@@ -13,7 +13,6 @@ __all__ = [
 
 import pygame
 import pygame_menu
-import pygame_menu.controls as ctrl
 import pygame_menu.events as _events
 import re
 import webbrowser
@@ -204,9 +203,9 @@ class Button(Widget):
 
             # User applies with key
             if event.type == pygame.KEYDOWN and self._keyboard_enabled and \
-                    event.key == ctrl.KEY_APPLY or \
+                    self._ctrl.apply(event, self) or \
                     event.type == pygame.JOYBUTTONDOWN and self._joystick_enabled and \
-                    event.button == ctrl.JOY_BUTTON_SELECT:
+                    self._ctrl.joy_select(event, self):
                 if self.to_menu:
                     self._sound.play_open_menu()
                 else:
diff --git a/pygame_menu/widgets/widget/colorinput.py b/pygame_menu/widgets/widget/colorinput.py
index a64011ea..658e1be6 100644
--- a/pygame_menu/widgets/widget/colorinput.py
+++ b/pygame_menu/widgets/widget/colorinput.py
@@ -431,13 +431,13 @@ class ColorInput(TextInput):  # lgtm [py/missing-call-to-init]
                             len(input_str) == cursor_pos + 1
                     ):
                         # Backspace button, delete text from right
-                        if event.key == pygame.K_BACKSPACE:
+                        if self._ctrl.back(event, self):
                             if len(input_str) >= 1 and \
                                     input_str[cursor_pos - 1] == self._separator:
                                 return True
 
                         # Delete button, delete text from left
-                        elif event.key == pygame.K_DELETE:
+                        elif self._ctrl.delete(event, self):
                             if input_str[cursor_pos] == self._separator:
                                 return True
 
@@ -505,12 +505,12 @@ class ColorInput(TextInput):  # lgtm [py/missing-call-to-init]
                         continue
 
                     # Backspace button, delete text from right
-                    if event.key == pygame.K_BACKSPACE:
+                    if self._ctrl.back(event, self):
                         if cursor_pos == 1:
                             return True
 
                     # Delete button, delete text from left
-                    elif event.key == pygame.K_DELETE:
+                    elif self._ctrl.delete(event, self):
                         if cursor_pos == 0:
                             return True
 
diff --git a/pygame_menu/widgets/widget/dropselect.py b/pygame_menu/widgets/widget/dropselect.py
index 80cd7249..8aa09adf 100644
--- a/pygame_menu/widgets/widget/dropselect.py
+++ b/pygame_menu/widgets/widget/dropselect.py
@@ -15,7 +15,6 @@ __all__ = [
 import math
 import pygame
 import pygame_menu
-import pygame_menu.controls as ctrl
 
 from abc import ABC
 from pygame_menu.font import FontType, get_font, assert_font
@@ -1129,28 +1128,26 @@ class DropSelect(Widget):
             joy_button_down = self._joystick_enabled and event.type == pygame.JOYBUTTONDOWN
 
             # Left button
-            if keydown and event.key == ctrl.KEY_MOVE_DOWN or \
-                    joy_hatmotion and event.value == ctrl.JOY_LEFT or \
-                    joy_axismotion and event.axis == ctrl.JOY_AXIS_X and \
-                    event.value < ctrl.JOY_DEADZONE:
+            if keydown and self._ctrl.move_down(event, self) or \
+                    joy_hatmotion and self._ctrl.joy_left(event, self) or \
+                    joy_axismotion and self._ctrl.joy_axis_x_left(event, self):
                 if not self.active:
                     continue
                 self._down()
                 return True
 
             # Right button
-            elif keydown and event.key == ctrl.KEY_MOVE_UP or \
-                    joy_hatmotion and event.value == ctrl.JOY_RIGHT or \
-                    joy_axismotion and event.axis == ctrl.JOY_AXIS_X and \
-                    event.value > -ctrl.JOY_DEADZONE:
+            elif keydown and self._ctrl.move_up(event, self) or \
+                    joy_hatmotion and self._ctrl.joy_right(event, self) or \
+                    joy_axismotion and self._ctrl.joy_axis_x_right(event, self):
                 if not self.active:
                     continue
                 self._up()
                 return True
 
             # Press enter
-            elif keydown and event.key == ctrl.KEY_APPLY or \
-                    joy_button_down and event.button == ctrl.JOY_BUTTON_SELECT:
+            elif keydown and self._ctrl.apply(event, self) or \
+                    joy_button_down and self._ctrl.joy_select(event, self):
                 if self.active and self._index >= 0:
                     self._sound.play_key_add()
                     self.apply(*self._items[self._index][1:])
@@ -1159,13 +1156,13 @@ class DropSelect(Widget):
                 return True
 
             # Press keys which active the drop but not apply
-            elif keydown and (event.key == ctrl.KEY_TAB):
+            elif keydown and self._ctrl.tab(event, self):
                 self._toggle_drop()
                 return True
 
             # Close the selection
-            elif keydown and (event.key == pygame.K_ESCAPE or
-                              event.key == pygame.K_BACKSPACE):
+            elif keydown and (self._ctrl.escape(event, self) or
+                              self._ctrl.back(event, self)):
                 if self.active:
                     self._toggle_drop()
                 return True
diff --git a/pygame_menu/widgets/widget/menubar.py b/pygame_menu/widgets/widget/menubar.py
index 05afb65b..644896f3 100644
--- a/pygame_menu/widgets/widget/menubar.py
+++ b/pygame_menu/widgets/widget/menubar.py
@@ -28,7 +28,6 @@ __all__ = [
 
 import pygame
 import pygame.gfxdraw as gfxdraw
-import pygame_menu.controls as ctrl
 
 from pygame_menu.locals import FINGERUP, POSITION_EAST, POSITION_WEST, POSITION_NORTH, \
     POSITION_SOUTH
@@ -550,7 +549,7 @@ class MenuBar(Widget):
 
             # User applies joy back button
             elif event.type == pygame.JOYBUTTONDOWN and self._joystick_enabled:
-                if event.button == ctrl.JOY_BUTTON_BACK:
+                if self._ctrl.joy_back(event, self):
                     self._sound.play_key_del()
                     self.apply()
                     return True
diff --git a/pygame_menu/widgets/widget/rangeslider.py b/pygame_menu/widgets/widget/rangeslider.py
index def64235..f5ff99af 100644
--- a/pygame_menu/widgets/widget/rangeslider.py
+++ b/pygame_menu/widgets/widget/rangeslider.py
@@ -22,7 +22,6 @@ __all__ = [
 import math
 import pygame
 import pygame_menu
-import pygame_menu.controls as ctrl
 
 from abc import ABC
 from pygame_menu.locals import POSITION_NORTH, POSITION_SOUTH
@@ -955,27 +954,27 @@ class RangeSlider(Widget):
             joy_button_down = self._joystick_enabled and event.type == pygame.JOYBUTTONDOWN
 
             # Left button
-            if keydown and event.key == ctrl.KEY_LEFT or \
-                    joy_hatmotion and event.value == ctrl.JOY_LEFT or \
-                    joy_axismotion and event.axis == ctrl.JOY_AXIS_X and event.value < ctrl.JOY_DEADZONE:
+            if keydown and self._ctrl.left(event, self) or \
+                    joy_hatmotion and self._ctrl.joy_left(event, self) or \
+                    joy_axismotion and self._ctrl.joy_axis_x_left(event, self):
                 if self._left_right(event, True):
                     return True
 
             # Right button
-            elif keydown and event.key == ctrl.KEY_RIGHT or \
-                    joy_hatmotion and event.value == ctrl.JOY_RIGHT or \
-                    joy_axismotion and event.axis == ctrl.JOY_AXIS_X and event.value > -ctrl.JOY_DEADZONE:
+            elif keydown and self._ctrl.right(event, self) or \
+                    joy_hatmotion and self._ctrl.joy_right(event, self) or \
+                    joy_axismotion and self._ctrl.joy_axis_x_right(event, self):
                 if self._left_right(event, False):
                     return True
 
             # Press enter
-            elif keydown and event.key == ctrl.KEY_APPLY or \
-                    joy_button_down and event.button == ctrl.JOY_BUTTON_SELECT:
+            elif keydown and self._ctrl.apply(event, self) or \
+                    joy_button_down and self._ctrl.joy_select(event, self):
                 self.apply()
                 return True
 
             # Tab, switch active slider
-            elif keydown and event.key == ctrl.KEY_TAB:
+            elif keydown and self._ctrl.tab(event, self):
                 if self._single:
                     continue
                 self._slider_selected = (False, True) if self._slider_selected[0] else (True, False)
diff --git a/pygame_menu/widgets/widget/selector.py b/pygame_menu/widgets/widget/selector.py
index c3b3aef1..b141573d 100644
--- a/pygame_menu/widgets/widget/selector.py
+++ b/pygame_menu/widgets/widget/selector.py
@@ -27,7 +27,6 @@ __all__ = [
 
 import pygame
 import pygame_menu
-import pygame_menu.controls as ctrl
 
 from abc import ABC
 from pygame_menu.locals import FINGERUP
@@ -454,22 +453,22 @@ class Selector(Widget):
             joy_button_down = self._joystick_enabled and event.type == pygame.JOYBUTTONDOWN
 
             # Left button
-            if keydown and event.key == ctrl.KEY_LEFT or \
-                    joy_hatmotion and event.value == ctrl.JOY_LEFT or \
-                    joy_axismotion and event.axis == ctrl.JOY_AXIS_X and event.value < ctrl.JOY_DEADZONE:
+            if keydown and self._ctrl.left(event, self) or \
+                    joy_hatmotion and self._ctrl.joy_left(event, self) or \
+                    joy_axismotion and self._ctrl.joy_axis_x_left(event, self):
                 self._left()
                 return True
 
             # Right button
-            elif keydown and event.key == ctrl.KEY_RIGHT or \
-                    joy_hatmotion and event.value == ctrl.JOY_RIGHT or \
-                    joy_axismotion and event.axis == ctrl.JOY_AXIS_X and event.value > -ctrl.JOY_DEADZONE:
+            elif keydown and self._ctrl.right(event, self) or \
+                    joy_hatmotion and self._ctrl.joy_right(event, self) or \
+                    joy_axismotion and self._ctrl.joy_axis_x_right(event, self):
                 self._right()
                 return True
 
             # Press enter
-            elif keydown and event.key == ctrl.KEY_APPLY or \
-                    joy_button_down and event.button == ctrl.JOY_BUTTON_SELECT:
+            elif keydown and self._ctrl.apply(event, self) or \
+                    joy_button_down and self._ctrl.joy_select(event, self):
                 self._sound.play_key_add()
                 self.apply(*self._items[self._index][1:])
                 return True
diff --git a/pygame_menu/widgets/widget/textinput.py b/pygame_menu/widgets/widget/textinput.py
index f66d708d..94045bb7 100644
--- a/pygame_menu/widgets/widget/textinput.py
+++ b/pygame_menu/widgets/widget/textinput.py
@@ -1672,7 +1672,7 @@ class TextInput(Widget):
                             pass
 
                 # Backspace button, delete text from right
-                if event.key == pygame.K_BACKSPACE:
+                if self._ctrl.back(event, self):
                     # Play sound
                     if self._cursor_position == 0:
                         self._sound.play_event_error()
@@ -1691,7 +1691,7 @@ class TextInput(Widget):
                     updated = True
 
                 # Delete button, delete text from left
-                elif event.key == pygame.K_DELETE:
+                elif self._ctrl.delete(event, self):
                     # Play sound
                     if self._cursor_position == len(self._input_string):
                         self._sound.play_event_error()
@@ -1710,7 +1710,7 @@ class TextInput(Widget):
                     self.active = True
 
                 # Right arrow
-                elif event.key == pygame.K_RIGHT:
+                elif self._ctrl.right(event, self):
                     # Play sound
                     if self._cursor_position == len(self._input_string):
                         self._sound.play_event_error()
@@ -1738,7 +1738,7 @@ class TextInput(Widget):
                     updated = True
 
                 # Left arrow
-                elif event.key == pygame.K_LEFT:
+                elif self._ctrl.left(event, self):
                     # Play sound
                     if self._cursor_position == 0:
                         self._sound.play_event_error()
@@ -1765,15 +1765,15 @@ class TextInput(Widget):
                     updated = True
 
                 # Up arrow
-                elif event.key == ctrl.KEY_MOVE_UP:
+                elif self._ctrl.move_up(event, self):
                     self.active = False
 
                 # Down arrow
-                elif event.key == ctrl.KEY_MOVE_DOWN:
+                elif self._ctrl.move_down(event, self):
                     self.active = False
 
                 # End
-                elif event.key == pygame.K_END:
+                elif self._ctrl.end(event, self):
                     self._sound.play_key_add()
                     self._cursor_position = len(self._input_string)
                     self._update_renderbox(end=True)
@@ -1782,7 +1782,7 @@ class TextInput(Widget):
                     updated = True
 
                 # Home
-                elif event.key == pygame.K_HOME:
+                elif self._ctrl.home(event, self):
                     self._sound.play_key_add()
                     self._cursor_position = 0
                     self._update_renderbox(start=True)
@@ -1791,14 +1791,14 @@ class TextInput(Widget):
                     updated = True
 
                 # Tab
-                elif event.key == ctrl.KEY_TAB:
+                elif self._ctrl.tab(event, self):
                     for _ in range(self._tab_size):
                         self._push_key_input(' ')
                         updated = True
                     self.active = True
 
                 # Enter
-                elif event.key == ctrl.KEY_APPLY:
+                elif self._ctrl.apply(event, self):
                     self._sound.play_open_menu()
                     self.apply()
                     self._unselect_text()
@@ -1806,7 +1806,7 @@ class TextInput(Widget):
                     self.active = not self.active
 
                 # Escape
-                elif event.key == pygame.K_ESCAPE:
+                elif self._ctrl.escape(event, self):
                     if self._get_selected_text():
                         self._unselect_text()
                         updated = True
@@ -1898,14 +1898,15 @@ class TextInput(Widget):
 
             # Generate new key events if enough time has passed:
             if self._keyrepeat_counters[key][0] >= self._keyrepeat_initial_interval_ms:
-                self._keyrepeat_counters[key][
-                    0] = self._keyrepeat_initial_interval_ms - self._keyrepeat_interval_ms
+                self._keyrepeat_counters[key][0] = \
+                    self._keyrepeat_initial_interval_ms - self._keyrepeat_interval_ms
 
                 event_key, event_unicode = key, self._keyrepeat_counters[key][1]
                 self._add_event(
-                    pygame.event.Event(pygame.KEYDOWN,
-                                       key=event_key,
-                                       unicode=event_unicode)
+                    pygame.event.Event(
+                        pygame.KEYDOWN,
+                        key=event_key,
+                        unicode=event_unicode)
                 )
 
         return updated
diff --git a/pygame_menu/widgets/widget/toggleswitch.py b/pygame_menu/widgets/widget/toggleswitch.py
index 6db45344..9a7536a1 100644
--- a/pygame_menu/widgets/widget/toggleswitch.py
+++ b/pygame_menu/widgets/widget/toggleswitch.py
@@ -13,7 +13,6 @@ __all__ = [
 
 import pygame
 import pygame_menu
-import pygame_menu.controls as ctrl
 
 from abc import ABC
 from pygame_menu.font import FontType, assert_font
@@ -417,23 +416,23 @@ class ToggleSwitch(Widget):
             joy_axismotion = self._joystick_enabled and event.type == pygame.JOYAXISMOTION
 
             # Left button
-            if keydown and event.key == ctrl.KEY_LEFT or \
-                    joy_hatmotion and event.value == ctrl.JOY_LEFT or \
-                    joy_axismotion and event.axis == ctrl.JOY_AXIS_X and event.value < ctrl.JOY_DEADZONE:
+            if keydown and self._ctrl.left(event, self) or \
+                    joy_hatmotion and self._ctrl.joy_left(event, self) or \
+                    joy_axismotion and self._ctrl.joy_axis_x_left(event, self):
                 self._left()
                 return True
 
             # Right button
-            elif keydown and event.key == ctrl.KEY_RIGHT or \
-                    joy_hatmotion and event.value == ctrl.JOY_RIGHT or \
-                    joy_axismotion and event.axis == ctrl.JOY_AXIS_X and event.value > -ctrl.JOY_DEADZONE:
+            elif keydown and self._ctrl.right(event, self) or \
+                    joy_hatmotion and self._ctrl.joy_right(event, self) or \
+                    joy_axismotion and self._ctrl.joy_axis_x_right(event, self):
                 self._right()
                 return True
 
             # Press enter
-            elif keydown and event.key == ctrl.KEY_APPLY and self._total_states == 2 or \
+            elif keydown and self._ctrl.apply(event, self) and self._total_states == 2 or \
                     event.type == pygame.JOYBUTTONDOWN and self._joystick_enabled and \
-                    event.button == ctrl.JOY_BUTTON_SELECT and self._total_states == 2:
+                    self._ctrl.joy_select(event, self) and self._total_states == 2:
                 self._sound.play_key_add()
                 self._state = int(not self._state)
                 self.change()


Test patch:
diff --git a/test/test_controls.py b/test/test_controls.py
index 96f6082a..8d6c7f29 100644
--- a/test/test_controls.py
+++ b/test/test_controls.py
@@ -68,14 +68,40 @@ class ControlsTest(BaseTest):
         # Now test new apply button
         button.update(PygameEventUtils.key(pygame.K_END, keydown=True))
         self.assertTrue(test[0])
+        button.update(PygameEventUtils.key(pygame.K_END, keydown=True))
+        self.assertFalse(test[0])
 
         # Rollback change
         ctrl.KEY_APPLY = pygame.K_RETURN
 
-        button.update(PygameEventUtils.key(pygame.K_END, keydown=True))
+        # Create new controller object
+        new_ctrl = ctrl.Controller()
+        test_apply = [0]
+
+        def new_apply(event, _) -> bool:
+            """
+            Updates apply.
+            """
+            test_apply[0] += 1
+            return event.key == pygame.K_a
+
+        new_ctrl.apply = new_apply
+        button.set_controller(new_ctrl)
+
+        # Now test new apply button
+        button.update(PygameEventUtils.key(pygame.K_a, keydown=True))
         self.assertTrue(test[0])
-        button.update(PygameEventUtils.key(ctrl.KEY_APPLY, keydown=True))
+        button.update(PygameEventUtils.key(pygame.K_a, keydown=True))
         self.assertFalse(test[0])
+        self.assertEqual(test_apply[0], 2)
+
+        button.update(PygameEventUtils.key(pygame.K_END, keydown=True))
+        self.assertFalse(test[0])
+        button.update(PygameEventUtils.key(ctrl.KEY_APPLY, keydown=True))
+        self.assertFalse(test[0])  # It should do nothing as object has new controller
+
+        # The same can be done with menu
+        menu.set_controller(new_ctrl)
 
     def test_pyautogui(self) -> None:
         """
diff --git a/test/test_scrollarea.py b/test/test_scrollarea.py
index bfc886f2..31c589b2 100644
--- a/test/test_scrollarea.py
+++ b/test/test_scrollarea.py
@@ -359,3 +359,13 @@ class ScrollAreaTest(BaseTest):
         self.assertEqual(sa.get_size(), (600, 400))
         self.assertEqual(sa.get_scrollbar_thickness(ORIENTATION_VERTICAL), 0)
         self.assertEqual(sa.get_scrollbar_thickness(ORIENTATION_HORIZONTAL), 0)
+
+    def test_change_area_color(self) -> None:
+        """
+        Test area color.
+        """
+        menu = MenuUtils.generic_menu()
+        sa = menu.get_scrollarea()
+        sf = sa._bg_surface
+        self.assertEqual(sa.update_area_color('red'), sa)
+        self.assertNotEqual(sf, sa._bg_surface)
diff --git a/test/test_widget_button.py b/test/test_widget_button.py
index 00bb3fb7..8f0f46e9 100644
--- a/test/test_widget_button.py
+++ b/test/test_widget_button.py
@@ -12,7 +12,9 @@ from test._utils import MenuUtils, surface, PygameEventUtils, BaseTest, PYGAME_V
 
 import pygame
 import pygame_menu
+import pygame_menu.controls as ctrl
 
+from pygame_menu.themes import THEME_DEFAULT
 from pygame_menu.widgets import Button
 from pygame_menu.widgets.core.widget import WIDGET_SHADOW_TYPE_ELLIPSE
 
@@ -258,7 +260,6 @@ class ButtonWidgetTest(BaseTest):
         self.assertFalse(btn.value_changed())
         btn.reset_value()
 
-    # noinspection HttpUrlsUsage
     def test_add_url(self) -> None:
         """
         Test add url.
@@ -266,7 +267,41 @@ class ButtonWidgetTest(BaseTest):
         menu = MenuUtils.generic_menu()
         self.assertRaises(AssertionError, lambda: menu.add.url('invalid'))
         self.assertRaises(AssertionError, lambda: menu.add.url('127.0.0.1'))
-        btn = menu.add.url('http://127.0.0.1')
-        self.assertEqual(btn.get_title(), 'http://127.0.0.1')
+        btn = menu.add.url('https://127.0.0.1')
+        self.assertEqual(btn.get_title(), 'https://127.0.0.1')
         btn2 = menu.add.url('https://github.com/ppizarror/pygame-menu', 'github')
         self.assertEqual(btn2.get_title(), 'github')
+
+    def test_controller(self) -> None:
+        """
+        Test controller.
+        """
+        theme = THEME_DEFAULT.copy()
+        menu = MenuUtils.generic_menu(theme=theme)
+        from pygame_menu.controls import Controller
+        from random import randrange
+        custom_controller = Controller()
+        test = [0]
+
+        # noinspection PyMissingOrEmptyDocstring
+        def btn_apply(event, _) -> bool:
+            applied = event.key in (pygame.K_a, pygame.K_b, pygame.K_c)
+            if applied:
+                menu.get_scrollarea().update_area_color((randrange(0, 255), randrange(0, 255), randrange(0, 255)))
+                test[0] += 1
+            return applied
+
+        custom_controller.apply = btn_apply
+        btn = menu.add.button('My button', lambda: print('Clicked!'))
+        btn.set_controller(custom_controller)
+        menu.update(PygameEventUtils.keydown(pygame.K_d))
+        self.assertEqual(test[0], 0)
+        menu.update(PygameEventUtils.keydown(pygame.K_a))
+        self.assertEqual(test[0], 1)
+        menu.update(PygameEventUtils.keydown(pygame.K_b))
+        self.assertEqual(test[0], 2)
+        menu.update(PygameEventUtils.keydown(pygame.K_c))
+        self.assertEqual(test[0], 3)
+
+        # Test select
+        self.assertTrue(btn.update(PygameEventUtils.joy_button(ctrl.JOY_BUTTON_SELECT)))