Repository: MyreMylar/pygame_gui (language: python)

Issue:
Add UIForm Element
Auto resizing containers added (Documentation on website is remaining)

These containers automatically resize when elements are added to them.

I was having some problems running tests, but I had tested them separately in a project I was using them in. More similar elements are coming like `UIAutoScrollingContainer `which uses an auto resizing container as its scrollable container instead of a normal `UIContainer`.
Check if you like this implementation, or maybe we could even merge `UIContainer `and the new `UIAutoResizingContainer`

**Adding PR split list to first post:**

**Easy changes**
1.  [x] Adding .idea to .gitignore PR - #530

**Changes to existing base classes**
1. [x] Changes being made to UIElement - This needs some merge effort with other recent changes, I had a stab locally but I think it can probably be done better. Also I need to interrogate what theses change are more fully as changes here can have a wide impact across the codebase as I discovered last week with a smaller change to dynamic resizing, that passed all the unit tests, managing to break an image resizing demo. #531, #534, #535, #536
2. [x] Changes to UIContainer - Same reasoning as above regarding wide ranging impact. #537
3. [x] Changes to UIScrollingContainer - I'm less concerned about changes here as it is not widely used, but it's worth looking at - there is also the possibility we may want to merge this class entirely with AutoScrollingContainer. #538, #539

**New stuff**
1. [x] AutoScrollingContainer #545
2. [x] AutoResizingContainer #543, #544
3. [x] UI2DSlider #540
4. [ ] UIForm

**Using the new stuff in existing stuff**
1. [x] UIColourPicker addition of UI2DSlider #542
Add UIForm Element
Auto resizing containers added (Documentation on website is remaining)

These containers automatically resize when elements are added to them.

I was having some problems running tests, but I had tested them separately in a project I was using them in. More similar elements are coming like `UIAutoScrollingContainer `which uses an auto resizing container as its scrollable container instead of a normal `UIContainer`.
Check if you like this implementation, or maybe we could even merge `UIContainer `and the new `UIAutoResizingContainer`

**Adding PR split list to first post:**

**Easy changes**
1.  [x] Adding .idea to .gitignore PR - #530

**Changes to existing base classes**
1. [x] Changes being made to UIElement - This needs some merge effort with other recent changes, I had a stab locally but I think it can probably be done better. Also I need to interrogate what theses change are more fully as changes here can have a wide impact across the codebase as I discovered last week with a smaller change to dynamic resizing, that passed all the unit tests, managing to break an image resizing demo. #531, #534, #535, #536
2. [x] Changes to UIContainer - Same reasoning as above regarding wide ranging impact. #537
3. [x] Changes to UIScrollingContainer - I'm less concerned about changes here as it is not widely used, but it's worth looking at - there is also the possibility we may want to merge this class entirely with AutoScrollingContainer. #538, #539

**New stuff**
1. [x] AutoScrollingContainer #545
2. [x] AutoResizingContainer #543, #544
3. [x] UI2DSlider #540
4. [ ] UIForm

**Using the new stuff in existing stuff**
1. [x] UIColourPicker addition of UI2DSlider #542


Discussion / hints:
The main question I had when looking over this is - are there circumstances where you would want a container that resizes like this, as opposed to one that resizes it's scrollable area (e.g. the way an explorer or one where you have decided the size ahead of time to fit the stuff you are going to put inside of it? Most UIs I see these days adopt a 'squish and vertical scroll' for oversized content - I suspect mainly because of the dominance of phones and tablets in browsing content. 

I suspect there are - but listing off some ideas as to what this would be used for in a UI would probably help clarify the design and help create an example that uses this type of container for the examples repository:

https://github.com/MyreMylar/pygame_gui_examples
 
I'll admit that I didn't have too much in mind except it would be easy to replace the `UIContainer` used in the `UIScrollableContainer` to this special container. I thought we would have to implement something like this either straight into the `UIScrollableContainer` otherwise anyways, and implementing it this way would potentially allow for more use cases to be available with the same effort for maintenance.
I think the usage possibility would be when you'd require a container to extend to contained elements but not to the point where you'd need scrollbars, by restricting the `maximum_edges` rect appropriately.
So for example when you want to put a `UIContainer` inside a `UIScrollableContainer`, you wouldn't want the inner container to have scrollbars regardless of how large it becomes because then you'd end up having a container with scrollbars inside a container with scrollbars.
I understand that this implementation doesn't have _a ton_ of more added freedom of usage, but it still adds at least _some_ more freedom to use this feature and I don't see any major downsides to it
Hi,
Thank you for reviewing the pull request. I also found some flaws with this code when resizing the container. I think there are certain other major problems with the class which I am trying to pin point properly.
However, I had already finished most work on the uiscrolling container which automatically resizes, and some other elements.
Yet I cannot guarantee that I will have the time to fix all the issues as I have some important exams coming up which will last the whole of February. I will try my best to debug the problems and update the necessary code, but I think it might be better to have the other elements' code added. Currently, I have these elements (almost) finished:
* UIAutoScrollingContainer
* UI2DSlider
* UIColourPickerDialog (with an added 2Dslider in the colour square which allows dragging to select the correct colour) 
* UIForm

These elements aren't fully completed, and I'm sure there are bugs but you'll be able to see what my approach to those elements is and guide me if necessary. You may even finish it if I am too inactive. It will atleast get the process of adding those elements going
While renaming the `UIContainer` method `on_anchor_target_changed` to `on_content_moved_or_resized` as you suggested, I saw that the UIElement class has a method called `_on_content_changed` which has no documentation. What exactly does this method do? Perhaps some documentation with that would make it unambiguous about which function does what.

Secondly, the `get_top_most_point` is not necessarily the same as getting the `self.rect.top`, because `self.rect.top` will not be changed to the y value from `get_top_most_point` unless `resize_top` is `True`. This means that using this method could be way to check what size the container will be in the case where `resize_top` is `False` and they wish to toggle it to `True`.
However, I can add the prefix if you feel like that use case is too specific and may confuse the users.
I made some changes using your suggestions. However, I can just not get the container to work properly. It seems to be having some problems with buttons and their texts, and especially with dynamic width and heights.
I think I found the root of the problem. So what happens when we create a new button with dynamic width and height is:
1. First, it creates a full sized image containing the text.
2. Then it sees how much of the image fits in the container, and crops the image accordingly. 
3. *However, it also resizes its rect to the size of the image.*
4.  This means that since the AutoResizingContainer checks whether it needs to expand using the rect of the button, it fails to recognise that the button exceeds the borders.
5.  Now since the container doesn't resize, no matter how many times you try to resize the button, the button will always be invisible since the rect doesn't change, the clipping rect doesn't change and the image size doesn't change.

So, to fix this problem, we'll have to **stop the image from resizing based on if it's outside the container**. Just update the clipping rect instead and as soon as the element moves update the clipping rect, and only blit the the image actually contained within the container.
I finally was able to fix the problem by eleminating the if condition in `UIElement._set_image` which checks whether the `image_clipping_rect` is 0 and sets the `universal_emtpy_surface` as the `self.image`. 
Looks like a crop of the unit tests are failing now I've enabled them to run.
> Looks like a crop of the unit tests are failing now I've enabled them to run.

I have tried fixing some of the failing tests with the next commit, could you try re-running the workflow? it doesn't allow me to run it myself. Also, when I run the tests in my IDE, many of them fail because the manager can't find the specified files, or the colours of the gradient don't match, etc. I don't think I have changed any code which would interfere with the colours or location of files. There is a test which specifically tests for the if condition I had removed, where in the expected image was supposed to be the `ui_manager.universal_empty_surface` but after I changed it (so that the size of the image isn't 0, 0) the tests fails.

I will also add the appropriate tests in a few days
> > Looks like a crop of the unit tests are failing now I've enabled them to run.
> 
> I have tried fixing some of the failing tests with the next commit, could you try re-running the workflow? it doesn't allow me to run it myself. Also, when I run the tests in my IDE, many of them fail because the manager can't find the specified files, or the colours of the gradient don't match, etc. I don't think I have changed any code which would interfere with the colours or location of files. There is a test which specifically tests for the if condition I had removed, where in the expected image was supposed to be the `ui_manager.universal_empty_surface` but after I changed it (so that the size of the image isn't 0, 0) the tests fails.
> 
> I will also add the appropriate tests in a few days

I have kicked off the tests.

To run them locally you should just be able to run `pytest tests` from the root directory (the one that has the 'tests', 'docs' and 'pygame_gui' folders. For me it looks like this:

![image](https://github.com/MyreMylar/pygame_gui/assets/13382426/82a4ce19-915d-4be0-b73c-3ca23bfc108b)
Okay, I have finally seemed to squash the bugs coming up with the containers while resizing. Also, I added some other functions and removed other bugs with this, namely:
1. `UIContainer` now also has `expand_top` and `expand_left` functions which work like `set_dimensions` except, as the name suggests, they expand the container from the top and left respectively. The big difference between just setting the dimensions and moving the rect, and these functions is that these functions also update the relative rects of the contained elements. This means that none of the contained elements' absolute positions will move.
2. I updated the `on_contained_elements_changed` method to recursively update targets as when there was a chain of elements all anchored to each other, it would not update all of them previously

I ran all the tests and it seems to be working properly. I tested the elements manually and they are working but I have not written the pytest equivalent tests yet. It's a bit daunting of a task because there are so many things to test.
If you run the tests again they should all be successful
Looks like things are coming along. These are the failing tests at the minute:

```
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_creation - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_create_too_small - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_press_cancel_button - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_press_ok_button - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_click_in_saturation_value_square_button - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_mess_with_colour_channel_event - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_update_saturation_value_square - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_update_current_colour_image - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_changed_rgb_update_hsv - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_changed_hsv_update_rgb - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_show - AttributeError: 'UI2DSlider' object has no attribute 'sliding_button'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_hide - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_show_hide_rendering - AttributeError: 'UI2DSlider' object has no attribute 'sliding_button'
```
And most of them look like the same fail there.
Ah, My "Fixed Potential Bug" commit is the culprit here. Added comments so that I don't unfix a bug again. However, 2 of the tests are still failling I'll see what is causing them

The tests were failing mainly because the `UIButton` in the `UI2DSlider` kept consuming the event so the colour wouldn't change. Usually this wouldn't be a problem because clicking the button in place shouldn't change the colour anyway, but because this was done by the program where the colour was set programmatically and the necessary values not updated properly, it would cause errors
Also, now there is a border around the sat square of the `UIColourPickerDialog` due to the `2DSlider`. You can decide whether you want that or not and set the border to transparent in the default file if you don't want it
One of the tests is failing but I can't figure out why. The changes I made really look right to me when I locally tested it, but I don't know which part of the code I changed is affecting the test and therefore whether that was by intention or not. Mysteriously, even though the test works with the horizontal scrollbar of the scrollable container, it doesn't with the vertical scrollbar
Why are the two scrollbars implemented a bit differently? could be something to do with it. I think I need your help debugging with this one, @MyreMylar because I can't even figure out what the bug is and whether it affects the container at all to the user
Aha! I have fixed the bugs. It had to do with the visible container being resized after calculations of the sizes of the scrollbars instead of before the calculations, as it was done before. I will start work on the tests, but some features remaining to add are:

1. Padding around auto resizing containers, because currently it is made so that it will fit perfectly to the elements inside clamped by its min and max edges rect, and being able to change that would help. but, I am not sure whether I should use the theming file or a value passed in during initialisation because I think other `IUIContainer` elements like `UIAutoScrollingContainer` and `UIForm` would like to use this functionality. If we use the theme file then I'm not sure how the other elements will use it.

2. Some more types of inputs in UIForm, like a colour input using colour pickers, or another type of field which are required to be input, and perhaps a password type input. I have created an issue, #526 for the second one. I have a way for the user of the library to create a questionnaire with required elements, but the actual presence check has not been implemented so it's currently not functional.

3. Validation of min and max edges rect in autoresizing containers to make sure that min edges rect is contained completely within max edges rect, otherwise raise a warning perhaps?
The main question I had when looking over this is - are there circumstances where you would want a container that resizes like this, as opposed to one that resizes it's scrollable area (e.g. the way an explorer or one where you have decided the size ahead of time to fit the stuff you are going to put inside of it? Most UIs I see these days adopt a 'squish and vertical scroll' for oversized content - I suspect mainly because of the dominance of phones and tablets in browsing content. 

I suspect there are - but listing off some ideas as to what this would be used for in a UI would probably help clarify the design and help create an example that uses this type of container for the examples repository:

https://github.com/MyreMylar/pygame_gui_examples
 
I'll admit that I didn't have too much in mind except it would be easy to replace the `UIContainer` used in the `UIScrollableContainer` to this special container. I thought we would have to implement something like this either straight into the `UIScrollableContainer` otherwise anyways, and implementing it this way would potentially allow for more use cases to be available with the same effort for maintenance.
I think the usage possibility would be when you'd require a container to extend to contained elements but not to the point where you'd need scrollbars, by restricting the `maximum_edges` rect appropriately.
So for example when you want to put a `UIContainer` inside a `UIScrollableContainer`, you wouldn't want the inner container to have scrollbars regardless of how large it becomes because then you'd end up having a container with scrollbars inside a container with scrollbars.
I understand that this implementation doesn't have _a ton_ of more added freedom of usage, but it still adds at least _some_ more freedom to use this feature and I don't see any major downsides to it
Hi,
Thank you for reviewing the pull request. I also found some flaws with this code when resizing the container. I think there are certain other major problems with the class which I am trying to pin point properly.
However, I had already finished most work on the uiscrolling container which automatically resizes, and some other elements.
Yet I cannot guarantee that I will have the time to fix all the issues as I have some important exams coming up which will last the whole of February. I will try my best to debug the problems and update the necessary code, but I think it might be better to have the other elements' code added. Currently, I have these elements (almost) finished:
* UIAutoScrollingContainer
* UI2DSlider
* UIColourPickerDialog (with an added 2Dslider in the colour square which allows dragging to select the correct colour) 
* UIForm

These elements aren't fully completed, and I'm sure there are bugs but you'll be able to see what my approach to those elements is and guide me if necessary. You may even finish it if I am too inactive. It will atleast get the process of adding those elements going
While renaming the `UIContainer` method `on_anchor_target_changed` to `on_content_moved_or_resized` as you suggested, I saw that the UIElement class has a method called `_on_content_changed` which has no documentation. What exactly does this method do? Perhaps some documentation with that would make it unambiguous about which function does what.

Secondly, the `get_top_most_point` is not necessarily the same as getting the `self.rect.top`, because `self.rect.top` will not be changed to the y value from `get_top_most_point` unless `resize_top` is `True`. This means that using this method could be way to check what size the container will be in the case where `resize_top` is `False` and they wish to toggle it to `True`.
However, I can add the prefix if you feel like that use case is too specific and may confuse the users.
I made some changes using your suggestions. However, I can just not get the container to work properly. It seems to be having some problems with buttons and their texts, and especially with dynamic width and heights.
I think I found the root of the problem. So what happens when we create a new button with dynamic width and height is:
1. First, it creates a full sized image containing the text.
2. Then it sees how much of the image fits in the container, and crops the image accordingly. 
3. *However, it also resizes its rect to the size of the image.*
4.  This means that since the AutoResizingContainer checks whether it needs to expand using the rect of the button, it fails to recognise that the button exceeds the borders.
5.  Now since the container doesn't resize, no matter how many times you try to resize the button, the button will always be invisible since the rect doesn't change, the clipping rect doesn't change and the image size doesn't change.

So, to fix this problem, we'll have to **stop the image from resizing based on if it's outside the container**. Just update the clipping rect instead and as soon as the element moves update the clipping rect, and only blit the the image actually contained within the container.
I finally was able to fix the problem by eleminating the if condition in `UIElement._set_image` which checks whether the `image_clipping_rect` is 0 and sets the `universal_emtpy_surface` as the `self.image`. 
Looks like a crop of the unit tests are failing now I've enabled them to run.
> Looks like a crop of the unit tests are failing now I've enabled them to run.

I have tried fixing some of the failing tests with the next commit, could you try re-running the workflow? it doesn't allow me to run it myself. Also, when I run the tests in my IDE, many of them fail because the manager can't find the specified files, or the colours of the gradient don't match, etc. I don't think I have changed any code which would interfere with the colours or location of files. There is a test which specifically tests for the if condition I had removed, where in the expected image was supposed to be the `ui_manager.universal_empty_surface` but after I changed it (so that the size of the image isn't 0, 0) the tests fails.

I will also add the appropriate tests in a few days
> > Looks like a crop of the unit tests are failing now I've enabled them to run.
> 
> I have tried fixing some of the failing tests with the next commit, could you try re-running the workflow? it doesn't allow me to run it myself. Also, when I run the tests in my IDE, many of them fail because the manager can't find the specified files, or the colours of the gradient don't match, etc. I don't think I have changed any code which would interfere with the colours or location of files. There is a test which specifically tests for the if condition I had removed, where in the expected image was supposed to be the `ui_manager.universal_empty_surface` but after I changed it (so that the size of the image isn't 0, 0) the tests fails.
> 
> I will also add the appropriate tests in a few days

I have kicked off the tests.

To run them locally you should just be able to run `pytest tests` from the root directory (the one that has the 'tests', 'docs' and 'pygame_gui' folders. For me it looks like this:

![image](https://github.com/MyreMylar/pygame_gui/assets/13382426/82a4ce19-915d-4be0-b73c-3ca23bfc108b)
Okay, I have finally seemed to squash the bugs coming up with the containers while resizing. Also, I added some other functions and removed other bugs with this, namely:
1. `UIContainer` now also has `expand_top` and `expand_left` functions which work like `set_dimensions` except, as the name suggests, they expand the container from the top and left respectively. The big difference between just setting the dimensions and moving the rect, and these functions is that these functions also update the relative rects of the contained elements. This means that none of the contained elements' absolute positions will move.
2. I updated the `on_contained_elements_changed` method to recursively update targets as when there was a chain of elements all anchored to each other, it would not update all of them previously

I ran all the tests and it seems to be working properly. I tested the elements manually and they are working but I have not written the pytest equivalent tests yet. It's a bit daunting of a task because there are so many things to test.
If you run the tests again they should all be successful
Looks like things are coming along. These are the failing tests at the minute:

```
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_creation - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_create_too_small - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_press_cancel_button - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_press_ok_button - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_click_in_saturation_value_square_button - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_mess_with_colour_channel_event - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_update_saturation_value_square - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_update_current_colour_image - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_changed_rgb_update_hsv - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_changed_hsv_update_rgb - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_show - AttributeError: 'UI2DSlider' object has no attribute 'sliding_button'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_hide - AttributeError: 'NoneType' object has no attribute 'width'
FAILED tests/test_windows/test_ui_colour_picker_dialog.py::TestUIColourPickerDialog::test_show_hide_rendering - AttributeError: 'UI2DSlider' object has no attribute 'sliding_button'
```
And most of them look like the same fail there.
Ah, My "Fixed Potential Bug" commit is the culprit here. Added comments so that I don't unfix a bug again. However, 2 of the tests are still failling I'll see what is causing them

The tests were failing mainly because the `UIButton` in the `UI2DSlider` kept consuming the event so the colour wouldn't change. Usually this wouldn't be a problem because clicking the button in place shouldn't change the colour anyway, but because this was done by the program where the colour was set programmatically and the necessary values not updated properly, it would cause errors
Also, now there is a border around the sat square of the `UIColourPickerDialog` due to the `2DSlider`. You can decide whether you want that or not and set the border to transparent in the default file if you don't want it
One of the tests is failing but I can't figure out why. The changes I made really look right to me when I locally tested it, but I don't know which part of the code I changed is affecting the test and therefore whether that was by intention or not. Mysteriously, even though the test works with the horizontal scrollbar of the scrollable container, it doesn't with the vertical scrollbar
Why are the two scrollbars implemented a bit differently? could be something to do with it. I think I need your help debugging with this one, @MyreMylar because I can't even figure out what the bug is and whether it affects the container at all to the user
Aha! I have fixed the bugs. It had to do with the visible container being resized after calculations of the sizes of the scrollbars instead of before the calculations, as it was done before. I will start work on the tests, but some features remaining to add are:

1. Padding around auto resizing containers, because currently it is made so that it will fit perfectly to the elements inside clamped by its min and max edges rect, and being able to change that would help. but, I am not sure whether I should use the theming file or a value passed in during initialisation because I think other `IUIContainer` elements like `UIAutoScrollingContainer` and `UIForm` would like to use this functionality. If we use the theme file then I'm not sure how the other elements will use it.

2. Some more types of inputs in UIForm, like a colour input using colour pickers, or another type of field which are required to be input, and perhaps a password type input. I have created an issue, #526 for the second one. I have a way for the user of the library to create a questionnaire with required elements, but the actual presence check has not been implemented so it's currently not functional.

3. Validation of min and max edges rect in autoresizing containers to make sure that min edges rect is contained completely within max edges rect, otherwise raise a warning perhaps?

Fix patch:
diff --git a/pygame_gui/__init__.py b/pygame_gui/__init__.py
index fee3647f..867a55d7 100644
--- a/pygame_gui/__init__.py
+++ b/pygame_gui/__init__.py
@@ -13,7 +13,7 @@
 from pygame_gui._constants import UI_BUTTON_ON_HOVERED, UI_BUTTON_ON_UNHOVERED
 from pygame_gui._constants import UI_TEXT_ENTRY_FINISHED, UI_TEXT_ENTRY_CHANGED
 from pygame_gui._constants import UI_TEXT_BOX_LINK_CLICKED
-from pygame_gui._constants import UI_DROP_DOWN_MENU_CHANGED, UI_HORIZONTAL_SLIDER_MOVED
+from pygame_gui._constants import UI_DROP_DOWN_MENU_CHANGED, UI_HORIZONTAL_SLIDER_MOVED, UI_2D_SLIDER_MOVED
 from pygame_gui._constants import UI_SELECTION_LIST_NEW_SELECTION
 from pygame_gui._constants import UI_SELECTION_LIST_DROPPED_SELECTION
 from pygame_gui._constants import UI_SELECTION_LIST_DOUBLE_CLICKED_SELECTION
@@ -46,6 +46,7 @@
            'UI_TEXT_ENTRY_FINISHED',
            'UI_DROP_DOWN_MENU_CHANGED',
            'UI_HORIZONTAL_SLIDER_MOVED',
+           'UI_2D_SLIDER_MOVED',
            'UI_SELECTION_LIST_NEW_SELECTION',
            'UI_SELECTION_LIST_DROPPED_SELECTION',
            'UI_SELECTION_LIST_DOUBLE_CLICKED_SELECTION',
diff --git a/pygame_gui/_constants.py b/pygame_gui/_constants.py
index 0600af83..11cc186c 100644
--- a/pygame_gui/_constants.py
+++ b/pygame_gui/_constants.py
@@ -62,6 +62,7 @@ def __eq__(self, other):
 UI_TEXT_ENTRY_FINISHED = custom_type()
 UI_DROP_DOWN_MENU_CHANGED = custom_type()
 UI_HORIZONTAL_SLIDER_MOVED = custom_type()
+UI_2D_SLIDER_MOVED = custom_type()
 UI_SELECTION_LIST_NEW_SELECTION = custom_type()
 UI_SELECTION_LIST_DROPPED_SELECTION = custom_type()
 UI_SELECTION_LIST_DOUBLE_CLICKED_SELECTION = custom_type()
@@ -244,6 +245,7 @@ def __eq__(self, other):
            'UI_TEXT_ENTRY_FINISHED',
            'UI_DROP_DOWN_MENU_CHANGED',
            'UI_HORIZONTAL_SLIDER_MOVED',
+           'UI_2D_SLIDER_MOVED',
            'UI_SELECTION_LIST_NEW_SELECTION',
            'UI_SELECTION_LIST_DROPPED_SELECTION',
            'UI_SELECTION_LIST_DOUBLE_CLICKED_SELECTION',
diff --git a/pygame_gui/data/default_theme.json b/pygame_gui/data/default_theme.json
index 2ec33eb3..8a263749 100644
--- a/pygame_gui/data/default_theme.json
+++ b/pygame_gui/data/default_theme.json
@@ -131,6 +131,27 @@
             "shadow_width": "1"
         }
     },
+    "2d_slider":
+    {
+        "prototype": "#default_shape_style",
+        "misc":
+        {
+            "sliding_button_width": "20"
+        }
+    },
+    "2d_slider.button":
+    {
+        "prototype": "#no_frills_button"
+    },
+    "2d_slider.#sliding_button":
+    {
+        "misc":
+        {
+            "shape": "ellipse",
+            "border_width": "1",
+            "shadow_width": "1"
+        }
+    },
     "label":
     {
         "misc":
diff --git a/pygame_gui/elements/__init__.py b/pygame_gui/elements/__init__.py
index be4aa1d4..97c535a9 100644
--- a/pygame_gui/elements/__init__.py
+++ b/pygame_gui/elements/__init__.py
@@ -1,6 +1,7 @@
 from pygame_gui.elements.ui_image import UIImage
 from pygame_gui.elements.ui_button import UIButton
 from pygame_gui.elements.ui_horizontal_slider import UIHorizontalSlider
+from pygame_gui.elements.ui_2d_slider import UI2DSlider
 from pygame_gui.elements.ui_vertical_scroll_bar import UIVerticalScrollBar
 from pygame_gui.elements.ui_horizontal_scroll_bar import UIHorizontalScrollBar
 from pygame_gui.elements.ui_label import UILabel
@@ -22,6 +23,7 @@
 __all__ = ['UIImage',
            'UIButton',
            'UIHorizontalSlider',
+           'UI2DSlider',
            'UIVerticalScrollBar',
            'UIHorizontalScrollBar',
            'UILabel',
diff --git a/pygame_gui/elements/ui_2d_slider.py b/pygame_gui/elements/ui_2d_slider.py
new file mode 100644
index 00000000..0033fc15
--- /dev/null
+++ b/pygame_gui/elements/ui_2d_slider.py
@@ -0,0 +1,493 @@
+import pygame
+from pygame_gui._constants import UI_2D_SLIDER_MOVED
+from pygame_gui.core import UIElement, UIContainer, ObjectID
+from pygame_gui.core.interfaces import IUIManagerInterface, IContainerLikeInterface, Coordinate
+from pygame_gui.core.drawable_shapes import RectDrawableShape, RoundedRectangleShape
+from pygame_gui.elements import UIButton
+
+import warnings
+from typing import *
+
+
+class UI2DSlider(UIElement):
+    """
+    A 2d slider is intended to help users adjust values within a range, for example a
+    volume control.
+
+    :param relative_rect: A rectangle describing the position and dimensions of the element.
+    :param start_value_x: The x value to start the slider at.
+    :param value_range_x: The full range of x values.
+    :param start_value_y: The y value to start the slider at.
+    :param value_range_y: The full range of y values.
+    :param invert_y: Should the y increase from bottom to top instead of the other way round?
+    :param manager: The UIManager that manages this element. If not provided or set to None,
+                    it will try to use the first UIManager that was created by your application.
+    :param container: The container that this element is within. If set to None will be the root
+                      window's container.
+    :param parent_element: The element this element "belongs to" in the theming hierarchy.
+    :param object_id: A custom defined ID for fine-tuning of theming.
+    :param anchors: A dictionary describing what this element's relative_rect is relative to.
+    :param visible: Whether the element is visible by default. Warning - container visibility
+                    may override this.
+    """
+
+    def __init__(self,
+                 relative_rect: pygame.Rect,
+                 start_value_x: Union[float, int],
+                 value_range_x: Union[Tuple[float, float], Tuple[int, int]],
+                 start_value_y: Union[float, int],
+                 value_range_y: Union[Tuple[float, float], Tuple[int, int]],
+                 invert_y: bool = False,
+                 manager: Optional[IUIManagerInterface] = None,
+                 container: Optional[IContainerLikeInterface] = None,
+                 starting_height: int = 1,
+                 parent_element: Optional[UIElement] = None,
+                 object_id: Optional[Union[ObjectID, str]] = None,
+                 anchors: Optional[Dict[str, Union[str, UIElement]]] = None,
+                 visible: int = 1
+                 ):
+
+        # Need to move some declarations early as they are indirectly referenced via the ui element
+        # constructor
+        self.sliding_button = None
+        self.button_container = None
+        super().__init__(relative_rect, manager, container,
+                         layer_thickness=2,
+                         starting_height=starting_height,
+                         anchors=anchors,
+                         visible=visible,
+                         parent_element=parent_element,
+                         object_id=object_id,
+                         element_id=["2d_slider"])
+
+        self.default_button_width = 20
+        self.sliding_button_width = self.default_button_width
+        self.current_x_percentage = 0.5
+        self.current_y_percentage = 0.5
+        self.left_limit_position = 0.0
+        self.top_limit_position = 0.0
+        self.starting_grab_difference = 0
+
+        self.invert_y = invert_y
+
+        if all([isinstance(n, int) for n in [start_value_x, *value_range_x, start_value_y, *value_range_y]]):
+            self.use_integers_for_value = True
+        else:
+            self.use_integers_for_value = False
+
+        self.value_range_x = value_range_x
+        value_range_x_length = self.value_range_x[1] - self.value_range_x[0]
+        self.current_x_value = self.value_range_x[0] + (self.current_x_percentage * value_range_x_length)
+
+        self.value_range_y = value_range_y
+        value_range_y_length = self.value_range_y[1] - self.value_range_y[0]
+        self.current_y_value = self.value_range_y[0] + (self.current_y_percentage * value_range_y_length)
+
+        if self.use_integers_for_value:
+            self.current_x_value = int(self.current_x_value)
+            self.current_y_value = int(self.current_y_value)
+
+        self.grabbed_slider = False
+        self.has_moved_recently = False
+        self.has_been_moved_by_user_recently = False
+
+        self.background_colour = None
+        self.border_colour = None
+        self.disabled_border_colour = None
+        self.disabled_background_colour = None
+
+        self.border_width = None
+        self.shadow_width = None
+
+        self.drawable_shape = None
+        self.shape = "rectangle"
+        self.shape_corner_radius = None
+
+        self.background_rect: Optional[pygame.Rect] = None
+
+        self.scrollable_width = None
+        self.scrollable_height = None
+        self.right_limit_position = None
+        self.bottom_limit_position = None
+        self.scroll_position: Optional[pygame.Vector2] = None
+
+        self.sliding_button = None
+
+        self.button_container = None
+
+        self.rebuild_from_changed_theme_data()
+
+        sliding_x_pos = int(self.background_rect.width / 2 - self.sliding_button_width / 2)
+        sliding_y_pos = int(self.background_rect.height / 2 - self.sliding_button_width / 2)
+        self.sliding_button = UIButton(pygame.Rect((sliding_x_pos, sliding_y_pos),
+                                                   (self.sliding_button_width,
+                                                    self.sliding_button_width)),
+                                       "", self.ui_manager,
+                                       container=self.button_container,
+                                       starting_height=1,
+                                       parent_element=self,
+                                       object_id=ObjectID(object_id="#sliding_button",
+                                                          class_id=None),
+                                       anchors={"left": "left",
+                                                "right": "left",
+                                                "top": "top",
+                                                "bottom": "top"},
+                                       visible=self.visible
+                                       )
+
+        self.sliding_button.set_hold_range((self.background_rect.width, self.background_rect.height))
+
+        self.set_current_value(start_value_x, start_value_y)
+
+    def rebuild(self):
+        """
+        Rebuild anything that might need rebuilding.
+
+        """
+        border_and_shadow = self.border_width + self.shadow_width
+        self.background_rect = pygame.Rect((border_and_shadow + self.relative_rect.x,
+                                            border_and_shadow + self.relative_rect.y),
+                                           (self.relative_rect.width - (2 * border_and_shadow),
+                                            self.relative_rect.height - (2 * border_and_shadow)))
+
+        theming_parameters = {"normal_bg": self.background_colour,
+                              "normal_border": self.border_colour,
+                              "disabled_bg": self.disabled_background_colour,
+                              "disabled_border": self.disabled_border_colour,
+                              "border_width": self.border_width,
+                              "shadow_width": self.shadow_width,
+                              "shape_corner_radius": self.shape_corner_radius}
+
+        if self.shape == "rectangle":
+            self.drawable_shape = RectDrawableShape(self.rect, theming_parameters,
+                                                    ["normal", "disabled"], self.ui_manager)
+        elif self.shape == "rounded_rectangle":
+            self.drawable_shape = RoundedRectangleShape(self.rect, theming_parameters,
+                                                        ["normal", "disabled"], self.ui_manager)
+
+        self._set_image(self.drawable_shape.get_fresh_surface())
+
+        if self.button_container is None:
+            self.button_container = UIContainer(self.background_rect,
+                                                manager=self.ui_manager,
+                                                container=self.ui_container,
+                                                anchors=self.anchors,
+                                                object_id="#2d_slider_buttons_container",
+                                                visible=self.visible)
+        else:
+            self.button_container.set_dimensions(self.background_rect.size)
+            self.button_container.set_relative_position(self.background_rect.topleft)
+
+        self.scrollable_width = (self.background_rect.width - self.sliding_button_width)
+        self.scrollable_height = (self.background_rect.height - self.sliding_button_width)
+        self.right_limit_position = self.scrollable_width
+        self.bottom_limit_position = self.scrollable_height
+        self.scroll_position = pygame.Vector2(self.scrollable_width / 2, self.scrollable_height / 2)
+
+        if self.sliding_button is not None:
+            self.sliding_button.set_dimensions((self.sliding_button_width,
+                                                self.sliding_button_width))
+            self.sliding_button.set_hold_range((self.background_rect.width, self.background_rect.height))
+            self.set_current_value(self.current_x_value, self.current_y_value)
+
+    def kill(self):
+        """
+        Overrides the normal sprite kill() method to also kill the button elements that help make
+        up the slider.
+
+        """
+        self.button_container.kill()
+        super().kill()
+
+    def update(self, time_delta: float):
+        """
+        Takes care of actually moving the slider based on interactions reported by the buttons or
+        based on movement of the mouse if we are gripping the slider itself.
+
+        :param time_delta: the time in seconds between calls to update.
+
+        """
+        super().update(time_delta)
+
+        if not (self.alive() and self.is_enabled):
+            return
+        moved_this_frame = False
+
+        mouse_pos = pygame.Vector2(self.ui_manager.get_mouse_position())
+        if self.sliding_button.held and self.sliding_button.in_hold_range(mouse_pos):
+            if not self.grabbed_slider:
+                self.grabbed_slider = True
+                real_scroll_pos = pygame.Vector2(self.sliding_button.rect.topleft)
+                self.starting_grab_difference = mouse_pos - real_scroll_pos
+
+            real_scroll_pos = pygame.Vector2(self.sliding_button.rect.topleft)
+            current_grab_difference = mouse_pos - real_scroll_pos
+            adjustment_required = current_grab_difference - self.starting_grab_difference
+            self.scroll_position = self.scroll_position + adjustment_required
+
+            self.scroll_position = pygame.Vector2(min(max(self.scroll_position.x, self.left_limit_position),
+                                                      self.right_limit_position),
+                                                  min(max(self.scroll_position.y, self.top_limit_position),
+                                                      self.bottom_limit_position))
+
+            self.sliding_button.set_relative_position(self.scroll_position)
+
+            moved_this_frame = True
+        elif not self.sliding_button.held:
+            self.grabbed_slider = False
+
+        if moved_this_frame:
+            self.current_x_percentage = self.scroll_position.x / self.scrollable_width
+            self.current_x_value = self.value_range_x[0] + (self.current_x_percentage *
+                                                            (self.value_range_x[1] - self.value_range_x[0]))
+
+            if self.invert_y:
+                # Scroll position is inverted, so invert it again to get actual percentage
+                self.current_y_percentage = (self.scrollable_height - self.scroll_position.y) / self.scrollable_height
+            else:
+                self.current_y_percentage = self.scroll_position.y / self.scrollable_height
+            self.current_y_value = self.value_range_y[0] + (self.current_y_percentage *
+                                                            (self.value_range_y[1] - self.value_range_y[0]))
+            if self.use_integers_for_value:
+                self.current_x_value = int(self.current_x_value)
+                self.current_y_value = int(self.current_y_value)
+
+            if not self.has_moved_recently:
+                self.has_moved_recently = True
+
+            if not self.has_been_moved_by_user_recently:
+                self.has_been_moved_by_user_recently = True
+
+            # new event
+            event_data = {"value": self.current_x_value,
+                          "ui_element": self,
+                          "ui_object_id": self.most_specific_combined_id}
+            pygame.event.post(pygame.event.Event(UI_2D_SLIDER_MOVED, event_data))
+
+    def get_current_value(self) -> Tuple[Union[float, float], Union[int, int]]:
+        """
+        Gets the current value the slider is set to.
+
+        :return: The current value recorded by the slider.
+
+        """
+        self.has_moved_recently = False
+        self.has_been_moved_by_user_recently = False
+        return self.current_x_value, self.current_y_value
+
+    def set_current_value(self, value_x: Union[float, int], value_y: Union[float, int], warn: bool = True) -> None:
+        """
+        Sets the value of the slider, which will move the position of the slider to match. Will
+        issue a warning if the value set is not in the value range.
+
+        :param value_x: The x value to set.
+        :param value_y: The y value to set.
+        :param warn: set to false in order to suppress the default warning,
+                     instead the value will be clamped.
+        :param: invert_y: Should the y value be inverted? If not passed then will use self.invert_value for this info.
+        :return: None
+        """
+        if self.use_integers_for_value:
+            value_x = int(value_x)
+            value_y = int(value_y)
+
+        min_x_value = min(self.value_range_x[0], self.value_range_x[1])
+        max_x_value = max(self.value_range_x[0], self.value_range_x[1])
+
+        if value_x < min_x_value or value_x > max_x_value:
+            if warn:
+                warnings.warn("x value not in range", UserWarning)
+                return
+            else:
+                self.current_x_value = max(min(value_x, max_x_value), min_x_value)
+        else:
+            self.current_x_value = value_x
+
+        min_y_value = min(self.value_range_y[0], self.value_range_y[1])
+        max_y_value = max(self.value_range_y[0], self.value_range_y[1])
+
+        if value_y < min_y_value or value_y > max_y_value:
+            if warn:
+                warnings.warn("y value not in range", UserWarning)
+                return
+            else:
+                self.current_y_value = max(min(value_y, max_y_value), min_y_value)
+        else:
+            self.current_y_value = value_y
+
+        value_x_range_size = (self.value_range_x[1] - self.value_range_x[0])
+        value_y_range_size = (self.value_range_y[1] - self.value_range_y[0])
+
+        if value_x_range_size != 0 and value_y_range_size != 0:
+            self.current_x_percentage = (float(self.current_x_value) -
+                                         self.value_range_x[0]) / value_x_range_size
+            self.current_y_percentage = (float(self.current_y_value) -
+                                         self.value_range_y[0]) / value_y_range_size
+
+            # Invert the position at which the slider should be
+            if self.invert_y:
+                height = self.scrollable_height * (1 - self.current_y_percentage)
+            else:
+                height = self.scrollable_height * self.current_y_percentage
+
+            self.scroll_position = (self.scrollable_width * self.current_x_percentage,
+                                    height)
+
+            self.sliding_button.set_relative_position(self.scroll_position)
+            self.has_moved_recently = True
+
+    def rebuild_from_changed_theme_data(self) -> None:
+        """
+        Called by the UIManager to check the theming data and rebuild whatever needs rebuilding for
+        this element when the theme data has changed.
+
+        :return: None
+        """
+        super().rebuild_from_changed_theme_data()
+        has_any_changed = False
+
+        if self._check_misc_theme_data_changed(attribute_name="shape",
+                                               default_value="rectangle",
+                                               casting_func=str,
+                                               allowed_values=["rectangle",
+                                                               "rounded_rectangle"]):
+            has_any_changed = True
+
+        if self._check_shape_theming_changed(defaults={"border_width": 1,
+                                                       "shadow_width": 2,
+                                                       "shape_corner_radius": 2}):
+            has_any_changed = True
+
+        background_colour = self.ui_theme.get_colour_or_gradient("dark_bg",
+                                                                 self.combined_element_ids)
+        if background_colour != self.background_colour:
+            self.background_colour = background_colour
+            has_any_changed = True
+
+        border_colour = self.ui_theme.get_colour_or_gradient("normal_border",
+                                                             self.combined_element_ids)
+        if border_colour != self.border_colour:
+            self.border_colour = border_colour
+            has_any_changed = True
+
+        disabled_background_colour = self.ui_theme.get_colour_or_gradient("disabled_dark_bg",
+                                                                          self.combined_element_ids)
+        if disabled_background_colour != self.disabled_background_colour:
+            self.disabled_background_colour = disabled_background_colour
+            has_any_changed = True
+
+        disabled_border_colour = self.ui_theme.get_colour_or_gradient("disabled_border",
+                                                                      self.combined_element_ids)
+        if disabled_border_colour != self.disabled_border_colour:
+            self.disabled_border_colour = disabled_border_colour
+            has_any_changed = True
+
+        if self._check_misc_theme_data_changed(attribute_name="sliding_button_width",
+                                               default_value=self.default_button_width,
+                                               casting_func=int):
+            has_any_changed = True
+
+        if has_any_changed:
+            self.rebuild()
+
+    def set_position(self, position: Coordinate) -> None:
+        """
+        Sets the absolute screen position of this slider, updating all subordinate button elements
+        at the same time.
+
+        :param position: The absolute screen position to set.
+        :return: None
+        """
+        super().set_position(position)
+
+        border_and_shadow = self.border_width + self.shadow_width
+        self.background_rect.x = border_and_shadow + self.relative_rect.x
+        self.background_rect.y = border_and_shadow + self.relative_rect.y
+
+        self.button_container.set_relative_position(self.background_rect.topleft)
+
+    def set_relative_position(self, position: Coordinate) -> None:
+        """
+        Sets the relative screen position of this slider, updating all subordinate button elements
+        at the same time.
+
+        :param position: The relative screen position to set.
+        :return: None
+        """
+        super().set_relative_position(position)
+
+        border_and_shadow = self.border_width + self.shadow_width
+        self.background_rect.x = border_and_shadow + self.relative_rect.x
+        self.background_rect.y = border_and_shadow + self.relative_rect.y
+
+        self.button_container.set_relative_position(self.background_rect.topleft)
+
+    def set_dimensions(self, dimensions: Coordinate,
+                       clamp_to_container: bool = False):
+        """
+        Method to directly set the dimensions of an element.
+
+        :param dimensions: The new dimensions to set.
+        :param clamp_to_container: Whether we should clamp the dimensions to the
+                                   dimensions of the container or not.
+        """
+        super().set_dimensions(dimensions, clamp_to_container)
+
+        border_and_shadow = self.border_width + self.shadow_width
+        self.background_rect.width = self.relative_rect.width - (2 * border_and_shadow)
+        self.background_rect.height = self.relative_rect.height - (2 * border_and_shadow)
+
+        self.button_container.set_dimensions(self.background_rect.size)
+
+        # sort out sliding button parameters
+        self.scrollable_width = (self.background_rect.width - self.sliding_button_width)
+        self.scrollable_height = (self.background_rect.height - self.sliding_button_width)
+        self.right_limit_position = self.scrollable_width
+        self.bottom_limit_position = self.scrollable_height
+        self.scroll_position = (self.scrollable_width * self.current_x_percentage,
+                                self.scrollable_height * self.current_y_percentage)
+
+        self.sliding_button.set_dimensions((self.sliding_button_width, self.sliding_button_width))
+        self.sliding_button.set_relative_position(self.scroll_position)
+
+    def disable(self):
+        """
+        Disable the slider. It should not be interactive and will use the disabled theme colours.
+        """
+        if self.is_enabled:
+            self.is_enabled = False
+            self.sliding_button.disable()
+            self.drawable_shape.set_active_state("disabled")
+
+    def enable(self):
+        """
+        Enable the slider. It should become interactive and will use the normal theme colours.
+        """
+        if not self.is_enabled:
+            self.is_enabled = True
+            self.sliding_button.enable()
+            self.drawable_shape.set_active_state("normal")
+
+    def show(self):
+        """
+        In addition to the base UIElement.show() - show the sliding button and show
+        the button_container which will propagate and show the left and right buttons.
+        """
+        super().show()
+
+        if self.sliding_button is not None:
+            self.sliding_button.show()
+        if self.button_container is not None:
+            self.button_container.show()
+
+    def hide(self):
+        """
+        In addition to the base UIElement.hide() - hide the sliding button and hide
+        the button_container which will propagate and hide the left and right buttons.
+        """
+        super().hide()
+
+        if self.sliding_button is not None:
+            self.sliding_button.hide()
+        if self.button_container is not None:
+            self.button_container.hide()


Test patch:
diff --git a/tests/data/themes/ui_2d_slider_bad_values.json b/tests/data/themes/ui_2d_slider_bad_values.json
new file mode 100644
index 00000000..ee0ff3ed
--- /dev/null
+++ b/tests/data/themes/ui_2d_slider_bad_values.json
@@ -0,0 +1,17 @@
+{
+    "2d_slider":
+    {
+        "colours":
+         {
+             "normal_border": "horse",
+             "dark_bg": "patrol"
+         },
+         "misc":
+         {
+             "shape": "grounded_drecktangle",
+             "shape_corner_radius": "cow",
+             "border_width": "0.0",
+             "shadow_width": "3.0"
+         }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/themes/ui_2d_slider_non_default.json b/tests/data/themes/ui_2d_slider_non_default.json
new file mode 100644
index 00000000..ee869ab4
--- /dev/null
+++ b/tests/data/themes/ui_2d_slider_non_default.json
@@ -0,0 +1,18 @@
+{
+    "2d_slider":
+    {
+        "colours":
+         {
+             "normal_border": "#AAAAAA,#FFFFFF,0",
+             "dark_bg": "#f4251b,#FF0000AA,90"
+         },
+         "misc":
+         {
+             "shape": "rounded_rectangle",
+             "shape_corner_radius": "6",
+             "border_width": "0",
+             "shadow_width": "3",
+             "sliding_button_width": "15"
+         }
+    }
+}
\ No newline at end of file
diff --git a/tests/test_elements/test_ui_2d_slider.py b/tests/test_elements/test_ui_2d_slider.py
new file mode 100644
index 00000000..3640d7cf
--- /dev/null
+++ b/tests/test_elements/test_ui_2d_slider.py
@@ -0,0 +1,447 @@
+import os
+
+import pygame
+import pytest
+
+from pygame_gui.core.interfaces import IUIManagerInterface
+from pygame_gui.core.ui_container import UIContainer
+from pygame_gui.elements.ui_2d_slider import UI2DSlider
+from pygame_gui.ui_manager import UIManager
+from tests.shared_comparators import compare_surfaces
+
+
+class TestUI2DSlider:
+
+    def test_creation(self, _init_pygame, default_ui_manager: IUIManagerInterface,
+                      _display_surface_return_none):
+        slider = UI2DSlider(relative_rect=pygame.Rect(100, 100, 100, 100),
+                            start_value_x=50,
+                            value_range_x=(0, 100),
+                            start_value_y=50,
+                            value_range_y=(0, 100),
+                            manager=default_ui_manager)
+        assert slider.image is not None
+
+    def test_rebuild(self, _init_pygame, default_ui_manager: IUIManagerInterface,
+                     _display_surface_return_none):
+        slider = UI2DSlider(relative_rect=pygame.Rect(100, 100, 100, 100),
+                            start_value_x=50,
+                            value_range_x=(0, 100),
+                            start_value_y=50,
+                            value_range_y=(0, 100),
+                            manager=default_ui_manager)
+        slider.rebuild()
+
+        assert slider.image is not None
+
+    def test_kill(self, _init_pygame, default_ui_manager: IUIManagerInterface,
+                  _display_surface_return_none):
+        slider = UI2DSlider(relative_rect=pygame.Rect(100, 100, 100, 100),
+                            start_value_x=50,
+                            value_range_x=(0, 100),
+                            start_value_y=50,
+                            value_range_y=(0, 100),
+                            manager=default_ui_manager)
+
+        assert len(default_ui_manager.get_root_container().elements) == 2
+        assert len(default_ui_manager.get_sprite_group().sprites()) == 4
+        assert default_ui_manager.get_sprite_group().sprites() == [default_ui_manager.get_root_container(),
+                                                                   slider,
+                                                                   slider.button_container,
+                                                                   slider.sliding_button]
+        slider.kill()
+        assert len(default_ui_manager.get_root_container().elements) == 0
+        assert len(default_ui_manager.get_sprite_group().sprites()) == 1
+        assert default_ui_manager.get_sprite_group().sprites() == [default_ui_manager.get_root_container()]
+
+    def test_check_has_moved_recently(self, _init_pygame, default_ui_manager,
+                                      _display_surface_return_none):
+        slider = UI2DSlider(relative_rect=pygame.Rect(100, 100, 100, 100),
+                            start_value_x=50,
+                            value_range_x=(0, 100),
+                            start_value_y=50,
+                            value_range_y=(0, 100),
+                            manager=default_ui_manager)
+
+        # move the scroll bar a bit
+        slider.sliding_button.held = True
+        pygame.mouse.set_pos((slider.sliding_button.get_abs_rect().centerx + 20,
+                              slider.sliding_button.get_abs_rect().centery))
+        slider.update(0.2)
+        assert slider.has_moved_recently is True
+
+    def test_check_update_sliding_bar(self, _init_pygame, default_ui_manager,
+                                      _display_surface_return_none):
+        slider = UI2DSlider(relative_rect=pygame.Rect(0, 0, 200, 30),
+                            start_value_x=50,
+                            value_range_x=(0, 100),
+                            start_value_y=50,
+                            value_range_y=(0, 100),
+                            manager=default_ui_manager)
+
+        # scroll down a bit then up again to exercise update
+        default_ui_manager.mouse_position = (100, 15)
+        slider.sliding_button.held = True
+        slider.update(0.3)
+
+        assert slider.grabbed_slider is True
+
+        slider.sliding_button.held = False
+        slider.update(0.3)
+
+        assert slider.grabbed_slider is False
+
+        non_y_invert_slider = UI2DSlider(relative_rect=pygame.Rect(0, 0, 200, 30),
+                                         start_value_x=50,
+                                         value_range_x=(0, 100),
+                                         start_value_y=50,
+                                         value_range_y=(0, 100),
+                                         invert_y=True,
+                                         manager=default_ui_manager)
+
+        # scroll down a bit then up again to exercise update
+        default_ui_manager.mouse_position = (100, 15)
+        non_y_invert_slider.sliding_button.held = True
+        non_y_invert_slider.update(0.3)
+
+        assert non_y_invert_slider.grabbed_slider is True
+
+        non_y_invert_slider.sliding_button.held = False
+        non_y_invert_slider.update(0.3)
+
+        assert non_y_invert_slider.grabbed_slider is False
+
+    def test_get_current_value(self, _init_pygame, default_ui_manager,
+                               _display_surface_return_none):
+        slider = UI2DSlider(relative_rect=pygame.Rect(100, 100, 100, 100),
+                            start_value_x=50,
+                            value_range_x=(0, 100),
+                            start_value_y=50,
+                            value_range_y=(0, 100),
+                            manager=default_ui_manager)
+
+        assert slider.get_current_value() == (50, 50)
+
+    def test_set_current_value_in_range(self, _init_pygame, default_ui_manager,
+                                        _display_surface_return_none):
+        slider = UI2DSlider(relative_rect=pygame.Rect(100, 100, 100, 100),
+                            start_value_x=50,
+                            value_range_x=(0, 100),
+                            start_value_y=50,
+                            value_range_y=(0, 100),
+                            manager=default_ui_manager)
+
+        slider.set_current_value(75, 75)
+        assert slider.get_current_value() == (75, 75)
+
+        slider = UI2DSlider(relative_rect=pygame.Rect(100, 100, 100, 100),
+                            start_value_x=50,
+                            value_range_x=(0, 100),
+                            start_value_y=50,
+                            value_range_y=(0, 100),
+                            invert_y=True,
+                            manager=default_ui_manager)
+
+        slider.set_current_value(75, 75)
+        assert slider.get_current_value() == (75, 75)
+
+    def test_set_current_value_out_of_range(self, _init_pygame, default_ui_manager,
+                                            _display_surface_return_none):
+        slider = UI2DSlider(relative_rect=pygame.Rect(100, 100, 100, 100),
+                            start_value_x=50,
+                            value_range_x=(0, 100),
+                            start_value_y=50,
+                            value_range_y=(0, 100),
+                            manager=default_ui_manager)
+
+        with pytest.warns(UserWarning, match='x value not in range'):
+            slider.set_current_value(200, 50)
+
+        slider.set_current_value(200, 50, warn=False)
+
+        assert slider.get_current_value() == (100, 50)
+
+        with pytest.warns(UserWarning, match='y value not in range'):
+            slider.set_current_value(50, 250)
+
+        slider.set_current_value(50, 250, warn=False)
+
+        assert slider.get_current_value() == (50, 100)
+
+    def test_rebuild_from_theme_data_non_default(self, _init_pygame,
+                                                 _display_surface_return_none):
+        manager = UIManager((800, 600), os.path.join("tests", "data",
+                                                     "themes",
+                                                     "ui_2d_slider_non_default.json"))
+
+        slider = UI2DSlider(relative_rect=pygame.Rect(100, 100, 100, 100),
+                            start_value_x=50,
+                            value_range_x=(0, 100),
+                            start_value_y=50,
+                            value_range_y=(0, 100),
+                            manager=manager)
+        assert slider.image is not None
+
+    @pytest.mark.filterwarnings("ignore:Invalid value")
+    @pytest.mark.filterwarnings("ignore:Colour hex code")
+    @pytest.mark.filterwarnings("ignore:Invalid Theme Colour")
+    def test_rebuild_from_theme_data_bad_values(self, _init_pygame,
+                                                _display_surface_return_none):
+        manager = UIManager((800, 600), os.path.join("tests",
+                                                     "data",
+                                                     "themes",
+                                                     "ui_2d_slider_bad_values.json"))
+
+        slider = UI2DSlider(relative_rect=pygame.Rect(100, 100, 100, 100),
+                            start_value_x=51,
+                            value_range_x=(0, 100),
+                            start_value_y=50,
+                            value_range_y=(0, 100),
+                            manager=manager)
+        assert slider.image is not None
+
+    def test_set_position(self, _init_pygame, default_ui_manager, _display_surface_return_none):
+        slider = UI2DSlider(relative_rect=pygame.Rect(300, 400, 200, 200),
+                            start_value_x=100,
+                            value_range_x=(0, 200),
+                            start_value_y=100,
+                            value_range_y=(0, 200),
+                            manager=default_ui_manager)
+
+        slider.set_position((200, 200))
+
+        # try to click on the sliding button
+        default_ui_manager.process_events(pygame.event.Event(pygame.MOUSEBUTTONDOWN,
+                                                             {'button': 1,
+                                                              'pos': (305, 305)}))
+        # if we successfully clicked on the moved slider then this button should be True
+        assert slider.sliding_button.held is True
+
+    def test_set_relative_position(self, _init_pygame, default_ui_manager,
+                                   _display_surface_return_none):
+        test_container = UIContainer(relative_rect=pygame.Rect(100, 100, 300, 300),
+                                     manager=default_ui_manager)
+        slider = UI2DSlider(relative_rect=pygame.Rect(300, 400, 150, 150),
+                            start_value_x=75,
+                            value_range_x=(0, 150),
+                            start_value_y=75,
+                            value_range_y=(0, 150),
+                            container=test_container,
+                            manager=default_ui_manager)
+
+        slider.set_relative_position((150.0, 150.0))
+
+        # try to click on the slider
+        default_ui_manager.process_events(pygame.event.Event(pygame.MOUSEBUTTONDOWN,
+                                                             {'button': 1,
+                                                              'pos': (330, 330)}))
+
+        assert slider.rect.topleft == (250, 250) and slider.sliding_button.held is True
+
+    def test_set_dimensions(self, _init_pygame, default_ui_manager, _display_surface_return_none):
+        slider = UI2DSlider(relative_rect=pygame.Rect(0, 0, 150, 150),
+                            start_value_x=100,
+                            value_range_x=(0, 200),
+                            start_value_y=100,
+                            value_range_y=(0, 200),
+                            manager=default_ui_manager)
+
+        slider.set_dimensions((200, 200))
+
+        # try to click on the slider
+        default_ui_manager.process_events(pygame.event.Event(pygame.MOUSEBUTTONDOWN,
+                                                             {'button': 1,
+                                                              'pos': (105, 105)}))
+        # if we successfully clicked on the moved slider then this button should be True
+        assert slider.sliding_button.held is True
+
+        slider.set_dimensions((100, 100))
+
+        # try to click on the slider
+        default_ui_manager.process_events(pygame.event.Event(pygame.MOUSEBUTTONDOWN,
+                                                             {'button': 1,
+                                                              'pos': (55, 55)}))
+        # if we successfully clicked on the moved slider then this button should be True
+        assert slider.sliding_button.held is True
+
+    def test_disable(self, _init_pygame: None, default_ui_manager: UIManager,
+                     _display_surface_return_none: None):
+        slider = UI2DSlider(relative_rect=pygame.Rect(0, 0, 150, 150),
+                            start_value_x=50,
+                            value_range_x=(0, 200),
+                            start_value_y=50,
+                            value_range_y=(0, 200),
+                            manager=default_ui_manager)
+
+        slider.disable()
+
+        # process a mouse button down event
+        slider.sliding_button.process_event(
+            pygame.event.Event(pygame.MOUSEBUTTONDOWN, {'button': 1,
+                                                        'pos': slider.sliding_button.rect.center}))
+        slider.update(0.1)
+
+        pygame.mouse.set_pos((slider.sliding_button.get_abs_rect().centerx + 20,
+                              slider.sliding_button.get_abs_rect().centery))
+        slider.update(0.1)
+
+        # process a mouse button up event
+        slider.sliding_button.process_event(
+            pygame.event.Event(pygame.MOUSEBUTTONUP, {'button': 1,
+                                                      'pos': slider.sliding_button.rect.center}))
+
+        assert slider.get_current_value() == (50, 50) and slider.is_enabled is False
+
+    def test_enable(self, _init_pygame: None, default_ui_manager: UIManager,
+                    _display_surface_return_none: None):
+        slider = UI2DSlider(relative_rect=pygame.Rect(0, 0, 200, 200),
+                            start_value_x=100,
+                            value_range_x=(0, 200),
+                            start_value_y=100,
+                            value_range_y=(0, 200),
+                            manager=default_ui_manager)
+
+        slider.disable()
+
+        slider.enable()
+
+        assert slider.is_enabled is True
+
+    def test_ints_in_ints_out(self, _init_pygame: None, default_ui_manager: UIManager,
+                              _display_surface_return_none: None):
+        slider = UI2DSlider(relative_rect=pygame.Rect(0, 0, 150, 150),
+                            start_value_x=50,
+                            value_range_x=(0, 200),
+                            start_value_y=50,
+                            value_range_y=(0, 200),
+                            manager=default_ui_manager)
+
+        current_value = slider.get_current_value()
+        assert isinstance(current_value, tuple)
+        assert isinstance(current_value[0], int)
+        assert isinstance(current_value[1], int)
+
+        # process a mouse button down event
+        slider.sliding_button.process_event(
+            pygame.event.Event(pygame.MOUSEBUTTONDOWN, {'button': 1,
+                                                        'pos': slider.sliding_button.rect.center}))
+
+        pygame.mouse.set_pos((slider.sliding_button.get_abs_rect().centerx + 20,
+                              slider.sliding_button.get_abs_rect().centery))
+        slider.update(0.1)
+
+        # process a mouse button up event
+        slider.sliding_button.process_event(
+            pygame.event.Event(pygame.MOUSEBUTTONUP, {'button': 1,
+                                                      'pos': slider.sliding_button.rect.center}))
+
+        current_value = slider.get_current_value()
+        assert isinstance(current_value, tuple)
+        assert isinstance(current_value[0], int)
+        assert isinstance(current_value[1], int)
+
+    def test_floats_in_floats_out(self, _init_pygame: None, default_ui_manager: UIManager,
+                                  _display_surface_return_none: None):
+        slider = UI2DSlider(relative_rect=pygame.Rect(0, 0, 150, 150),
+                            start_value_x=25.0,
+                            value_range_x=(0.0, 200.0),
+                            start_value_y=25.0,
+                            value_range_y=(0.0, 200.0),
+                            manager=default_ui_manager)
+
+        current_value = slider.get_current_value()
+        assert isinstance(current_value, tuple)
+        assert isinstance(current_value[0], float)
+        assert isinstance(current_value[1], float)
+
+        # process a mouse button down event
+        slider.sliding_button.process_event(
+            pygame.event.Event(pygame.MOUSEBUTTONDOWN, {'button': 1,
+                                                        'pos': slider.sliding_button.rect.center}))
+
+        pygame.mouse.set_pos((slider.sliding_button.get_abs_rect().centerx + 20,
+                              slider.sliding_button.get_abs_rect().centery))
+        slider.update(0.1)
+
+        # process a mouse button up event
+        slider.sliding_button.process_event(
+            pygame.event.Event(pygame.MOUSEBUTTONUP, {'button': 1,
+                                                      'pos': slider.sliding_button.rect.center}))
+
+        current_value = slider.get_current_value()
+        assert isinstance(current_value, tuple)
+        assert isinstance(current_value[0], float)
+        assert isinstance(current_value[1], float)
+
+    def test_show(self, _init_pygame, default_ui_manager, _display_surface_return_none):
+        slider = UI2DSlider(relative_rect=pygame.Rect(0, 0, 150, 40),
+                            start_value_x=50,
+                            value_range_x=(0, 200),
+                            start_value_y=50,
+                            value_range_y=(0, 200),
+                            manager=default_ui_manager, visible=0)
+
+        assert slider.visible == 0
+
+        assert slider.sliding_button.visible == 0
+        assert slider.button_container.visible == 0
+
+        slider.show()
+
+        assert slider.visible == 1
+
+        assert slider.sliding_button.visible == 1
+        assert slider.button_container.visible == 1
+
+    def test_hide(self, _init_pygame, default_ui_manager, _display_surface_return_none):
+        slider = UI2DSlider(relative_rect=pygame.Rect(0, 0, 150, 40),
+                            start_value_x=50,
+                            value_range_x=(0, 200),
+                            start_value_y=50,
+                            value_range_y=(0, 200),
+                            manager=default_ui_manager)
+
+        assert slider.visible == 1
+
+        assert slider.sliding_button.visible == 1
+        assert slider.button_container.visible == 1
+
+        slider.hide()
+
+        assert slider.visible == 0
+
+        assert slider.sliding_button.visible == 0
+        assert slider.button_container.visible == 0
+
+    def test_show_hide_rendering(self, _init_pygame, default_ui_manager, _display_surface_return_none):
+        resolution = (400, 400)
+        empty_surface = pygame.Surface(resolution)
+        empty_surface.fill(pygame.Color(0, 0, 0))
+
+        surface = empty_surface.copy()
+        manager = UIManager(resolution)
+        slider = UI2DSlider(relative_rect=pygame.Rect(25, 25, 375, 375),
+                            start_value_x=50,
+                            value_range_x=(0, 200),
+                            start_value_y=50,
+                            value_range_y=(0, 200),
+                            manager=manager, visible=0)
+        manager.update(0.01)
+        manager.draw_ui(surface)
+        assert compare_surfaces(empty_surface, surface)
+
+        surface.fill(pygame.Color(0, 0, 0))
+        slider.show()
+        manager.update(0.01)
+        manager.draw_ui(surface)
+        assert not compare_surfaces(empty_surface, surface)
+
+        surface.fill(pygame.Color(0, 0, 0))
+        slider.hide()
+        manager.update(0.01)
+        manager.draw_ui(surface)
+        assert compare_surfaces(empty_surface, surface)
+
+
+if __name__ == '__main__':
+    pytest.console_main()