-
Notifications
You must be signed in to change notification settings - Fork 0
Documentation
This document will talk about the structure of the package and the design choices that were made. This will include for example the class hierarchy, advanced features like conditional styling and common mistakes.
The package is divided into multiple subpackages:
pygame_utils_likeablejuniper
├─ core
│ └─ element.py
├─ element
│ ├─ label.py
│ ├─ button.py
│ ├─ container.py
│ ...
├─ style
│ ├─ style.py
│ └─ theme.py
└─ layout
└─ layout.py
Although this may look complicated to import, you can still import everything you need as usual:
from pygame_utils_likeablejuniper import Label, Button, VerticalLayoutThis works because the __init__.py file creates an importable alias for all user-relevant classes.
The rest of this document will focus on the subpackages and the classes contained therein, and also how to write your own subclasses and have them work with the existing classes.
The only significant class contained in the core subpackage is GUIElement. This is the base class for all major classes in the subpackage element. This class just describes some drawable and updatable element that the user can see and/or interact with. Its public API looks like this:
class GUIElement:
def __init__(self, rect: list[float], style: Style | None, base_style: Style):
...
def update(self, events: Iterable[pygame.Event]):
...
def draw(self, screen: pygame.Surface):
...
def update_style(self, style: Style):
...
def add_conditional_style(self, condition: Callable[[Self], bool], style: Style):
...GUIElement also has some protected and private methods. Of these, only the protected method is relevant for understanding how elements work:
def _rerender(self):
...Each element also tracks certain builtin booleans which you can use in style conditions, for example hovered, enabled or visible.
Constructing a GUIElement requires only one thing: a rect. Rects are a concept from pygame. They are also just a generally convenient and short method of representing rectangle-like areas. Every GUIElement covers a rectangle-like area, there are no truly "round" elements (this is similar to popular rendering implementations, like HTML). The rect contains 4 floats, the elements x and y position and the elements width and height.
You may optionally pass a Style-object, but this will be covered in the corresponding chapter about styles.
Every GUIElement has a hook for reacting to pygame events such as button presses or scrolling. Each element also has a method which draws the element to the given pygame surface. Since you create the elements, you are responsible for calling these methods. The methods need to be called every frame and always need to be passed the correct parameters or they will not work.
The update method requires an Iterable of pygame events, which you can obtain simply by calling pg.event.get() every frame and passing the result to the element.
The draw method requires a pygame surface, this should usually be your screen (or similar) variable obtained via pg.display.get_surface() or pg.display.set_mode(). If you know what you're doing, you may also specify any surface to split your application into multiple separately drawable surfaces.
You can avoid calling these methods on every element manually by adding all elements to a container and then calling update and draw on the container once. You can even nest containers!
Since these are two style methods, they will be covered in the style chapter.
This method reloads all drawing-relevant data for this element. This is one of the core pieces of an element, as it vastly improves performance over simply calculating all data every frame. Unfortunately this also makes elements a bit more difficult to understand.
Whenever something about an element changes that influences the way it is displayed (for example it becomes hovered or the text is updated), this method is called. If you feel like you need to call this method manually for changes to style, text or anything visible to update, you're probably using the API wrong.
Since this method is only protected, it can and should be overriden by extending classes. Since most elements define properties which are unique to them, they also need extra render logic. To keep performance high, it is not recommended to calculate that data every frame, and instead calculate it during _rerender and then save it to instance variables.
If you are interested in creating your own elements, look at the corresponding documentation.
Each element implementation has a file. That file not only defines the actual GUIElement implementation itself, but also the styles for that element. Some elements may also define multiple variations of the same element.
The Label element is one of these classes, although you should probably only ever use one of them. The implementation defines two types of labels; a classic Label and a StaticLabel. The main difference is the usage of _rerender. Label does not override the _rerender method, and therefore does not have the expected performance savings. The StaticLabel class on the other hand, does override the _rerender method. This means that, when you have text which changes pretty much every frame (for example a countdown with millisecond accuracy), you can use the basic Label class. For all other cases, it is recommended to use StaticLabel, since StaticLabel's worst case (_rerender being called every frame) is just barely worse than Label. The API for both labels is identical.
from pygame_utils_likeablejuniper import StaticLabel
label = StaticLabel([10, 10, 200, 50], "Hello Pygame")
while running:
label.update(pg.event.get())
label.draw(screen)The Button element is, alongside the label, one of the most basic GUI elements you could think of. The button contains the expected on-click functionality, provided through a Callable type argument. You may pass a lambda or method reference which takes no arguments and returns any type, the result is just ignored.
The button element also is the only element to have a default conditional style (a hover effect).
from pygame_utils_likeablejuniper import Button
def on_click():
print("I was clicked")
button = Button([10, 10, 200, 50], "Click Me!", on_click)
while running:
button.update(pg.event.get())
button.draw(screen)Containers are a special kind of element. They are still pretty basic in terms of GUIs, but they're quite different to labels and buttons. Containers can contain and manage multiple other GUIElements and serve as a grouping element.
All elements you add to a container will be managed by that container. What that means is that a forwards its update and draw calls to all child elements.
from pygame_utils_likeablejuniper import StaticLabel, Button, Container
container = Container([10, 10, 780, 100])
button = Button([190, 25, 200, 50], "Click Me!", on_click)
container.add(button)
label = StaticLabel([610, 25, 200, 50], "Hello")
container.add(label)
while running:
container.update(pg.event.get())
container.draw(screen)Note how you only need one update and one draw call per loop, instead of the expected 2 (1 for each element). This is one of the main advantages of containers over raw lists.
The other advantage is layouting, which will be discussed in the corresponding chapter.
Style is the base class for all styles. Since each element has different style attributes, the base Style class does not define anything other than an abstract apply() method. However, this method is mainly used for internal advanced styling and should not be used by users for styling.
Every element has a default style as a static class field. This means that, when constructing an element without submitting a style, there is still some default styling to fall back to.
In case you want to override the default style for a single element, you can create your own Style instance for that element type and populate it with the style attributes you want:
from pygame_utils_likeablejuniper import StaticLabel, LabelStyle
label = StaticLabel([10, 10, 200, 50], "Styled Label", style=LabelStyle(color=(255, 0, 127)))Notice how you don't need to fill all style attributes to create custom styles for single elements. The attributes you've left out will just be filled in by the element's default style.
This package does not give you the option to set specific hover/click/etc styles, instead, you get the option to set any condition for your styles. Using the GUIElement add_conditional_style method, you can define styles which only apply under a certain condition. Let's look at the method signature:
def add_conditional_style(self, condition: Callable[[Self], bool], style: Style):
...As the first argument, the method takes a Callable, the 'condition' for this style. The Callable takes the GUIElement itself as an input and returns a boolean, true if the style should be applied and false if not. With the GUIElement argument, you can make use of the internally tracked booleans like hovered:
from pygame_utils_likeablejuniper import StaticLabel, LabelStyle
label1 = StaticLabel([190, 10, 200, 50], "Hello condition") # at this point, label1 has only the default style
label1.add_conditional_style(lambda label: label.hovered, LabelStyle(color=(0, 0, 255))) # this turns the label text blue when it is hovered
counter = 0
label2 = StaticLabel([610, 10, 200, 50], "Custom condition")
label2.add_conditional_style(lambda label: counter > 3, LabelStyle(background_color=(255, 0, 0)))Notice how you can even define custom conditions which do not make use of any internal booleans. These styles will still update correctly if you call update every frame.
If you add multiple conditional styles to an element, they will be applied in the order that they are added. Lets say we have style A under condition a, style B under condition b and style C under condition c which were added to the element in that order.If only a and c are true, the styles will be applied like:
is a true? yes, apply style A is b true? no, skip is c true? yes, apply style C
This means that, if C has any style attributes in common with A, those from C will override those from A.
You can even update conditional style attributes:
from pygame_utils_likeablejuniper import StaticLabel, LabelStyle
style = LabelStyle(color=(255, 0, 0))
label = StaticLabel([300, 10, 200, 50], "Hello")
label.add_conditional_style(lambda label: label.hovered, style)
# later in the code
style.color = (0, 255, 0)
# THIS WILL NOT WORK
style = LabelStyle(color=(0, 255, 0))As long as you don't replace the entire reference, the conditional style will be updated as well
There is currently no way to remove specific styles or define default conditional styles, although this might change in a future release.
A theme is a collection of CompletedStyle objects which can be collectively applied as a default to all elements they are for. A completed style is a special kind of style which requires all style attributes to have an assigned value. You can define a custom theme like:
from pygame_utils_likeablejuniper import Theme, CompleteLabelStyle, CompleteButtonStyle
custom_theme = Theme([
CompleteLabelStyle(background_color=(161, 253, 255), text_color=(0, 0, 0), font=pg.font.SysFont("Comic Sans", 18)),
CompleteButtonStyle(background_color=(161, 253, 255), border=Border(0, (0, 0, 0)), text_color=(0, 0, 0), font=pg.font.SysFont("Mono", 20))
])
custom_theme.apply() # applies the styles to all elements styled by this theme (here labels and buttons)It is not necessary for themes to define styles for ALL element types, the ones left out will just stay on their default settings when the theme is applied. This means you can also mix certain incomplete themes for more customizability.