lots more documentation

This commit is contained in:
James Turk 2024-04-26 14:48:30 -05:00
parent a403490df0
commit ee898302d3
10 changed files with 443 additions and 139 deletions

View File

@ -1,7 +1,27 @@
"""
This file exposes the "public interface" for the library.
While you've often dealt with blank __init__ files, this is
making explicit which classes and functions are meant
to be used outside of the library.
Importing these here means a user can import these as
"from doodle import Line" instead of "from doodle.lines import Line"
This means the library author(s) can move around the implementation
as they wish without disrupting users.
For small libraries, it makes sense for a single __init__ to do this,
whereas for larger libraries like Django it is not common to require
users to import the specific portions they're using. (Though even
then there are public/non-public files.)
"""
from .doodles import Doodle, Group from .doodles import Doodle, Group
from .lines import Line from .lines import Line
from .shapes import Circle, Rectangle from .shapes import Circle, Rectangle
from .color import Color from .color import Color
from .text import Text from .text import Text
__all__ = [Doodle, Group, Line, Circle, Rectangle, Color, Text] __all__ = [Doodle, Group, Line, Circle, Rectangle, Color, Text]

View File

@ -7,8 +7,15 @@ class Color:
This is done to group many global variables into a namespace for clarity. This is done to group many global variables into a namespace for clarity.
There is no intention for anyone to ever declare an instance of this class. There is no reason for anyone to ever declare an instance of this class.
Instead it will be used like Color.BLACK Instead it will be used like Color.BLACK.
Another option would be to just have these be bare methods
at the `color` module and encourage use like color.random()
The two are equivalent, but this way was chosen as a demonstration
and to make `from colors import random` impossible, since that would
be confusing downstream.
Palette Source: https://pico-8.fandom.com/wiki/Palette Palette Source: https://pico-8.fandom.com/wiki/Palette
""" """
@ -30,6 +37,9 @@ class Color:
PINK = (255, 119, 168) PINK = (255, 119, 168)
LIGHT_PEACH = (255, 204, 170) LIGHT_PEACH = (255, 204, 170)
def __init__(self):
raise NotImplementedError("Color is not meant to be invoked directly")
@staticmethod @staticmethod
def all(): def all():
colors = list(Color.__dict__.values()) colors = list(Color.__dict__.values())
@ -38,10 +48,3 @@ class Color:
@staticmethod @staticmethod
def random(): def random():
return random.choice(Color.all()) return random.choice(Color.all())
# Another option would be to just have these be bare methods at the `color` module and
# encourage use like color.random()
# The two are equivalent, but this way makes someone accidentally importing `random()`
# impossible.

View File

@ -1,6 +1,19 @@
"""
This module defines the base interface that all Doodle objects
will need to implement.
It defines two classes: Doodle and Group.
Their docstrings will give specific detail, but the implementations
are closely linked, as a Group is both a type of Doodle and contains
a collection of additional Doodle-derived classes.
This is a reasonable example of when two classes might reasonable share a file.
"""
import random import random
import copy import copy
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Callable, Self
from .color import Color from .color import Color
from .world import world from .world import world
@ -17,18 +30,35 @@ class Doodle(ABC):
Doodles are drawn relative to their parent, so if a circle Doodles are drawn relative to their parent, so if a circle
is placed at (100, 100) and has a child point placed at (10, 10) is placed at (100, 100) and has a child point placed at (10, 10)
that point would appear at (110, 110). that point would appear ag (110, 110).
Careful attention is paid in this inheritance hierarchy to the Careful attention is paid in this inheritance hierarchy to the
Liskov substitution principle. Liskov substitution principle.
""" """
# annotations for instance attributes
_parent: Self | None
_updates: list[Callable]
_color: tuple[int, int, int]
_alpha: int
_z_index: int
def __init__(self, parent=None): def __init__(self, parent=None):
# To avoid all child constructors having an ever-expanding
# number of parameters as more customization options arrive,
# the design decision was made
# that at creation time, all Doodles will have defaults
# positioned at (0, 0), black, opaque, etc.
#
# All child classes should follow this same guidance
# setting a *visible* (i.e. non-zero) default.
self._parent = parent self._parent = parent
self._updates = [] self._updates = []
self._color = parent._color if parent else Color.BLACK self._color = parent._color if parent else Color.BLACK
self._alpha = parent._alpha if parent else 255 self._alpha = parent._alpha if parent else 255
self._z_index = 0 self._z_index = 0
# Design Note:
# Is storing this vector in a tuple the right thing to do? # Is storing this vector in a tuple the right thing to do?
# It might make more sense to store _x and _y, or use # It might make more sense to store _x and _y, or use
# a library's optimized 2D vector implementation. # a library's optimized 2D vector implementation.
@ -36,12 +66,19 @@ class Doodle(ABC):
# All references to _pos_vec are internal to the class, # All references to _pos_vec are internal to the class,
# so it will be trivial to swap this out later. # so it will be trivial to swap this out later.
self._pos_vec = (0, 0) self._pos_vec = (0, 0)
self._register() self._register()
def _register(self): def _register(self):
"""register with parent and world""" """
This private method ensures that the parent
knows about the child object.
It also registers every drawable object with the
World singleton (see world.py) which ensures that
it is drawn.
"""
if self._parent: if self._parent:
# register with parent for updates
self._parent.add(self) self._parent.add(self)
world.add(self) world.add(self)
@ -57,6 +94,31 @@ class Doodle(ABC):
""" """
pass pass
def copy(self) -> Self:
"""
It will be useful to have the ability to obtain a copy
of a given doodle to create repetitive designs.
This method is provided to fit the chained-object pattern
that will be used by the rest of the Doodle API.
Additionally, while a shallow copy is enough for most
cases, it will be possible for child classes to override
this.
"""
new = copy.copy(self)
new._register()
return new
# Dynamic Update Logic ############
# These methods relate to a WIP feature
# designed to demonstrate a functional hybrid
# approach to having objects update themselves.
#
# This feature isn't complete, or documented yet.
# TODO
def register_update(self, method, *args): def register_update(self, method, *args):
self._updates.append((method, args)) self._updates.append((method, args))
@ -72,25 +134,16 @@ class Doodle(ABC):
evaled_args = [arg() for arg in args] evaled_args = [arg() for arg in args]
method(*evaled_args) method(*evaled_args)
def copy(self) -> "Doodle":
"""
It will be useful to have the ability to obtain a copy
of a given doodle to create repetitive designs.
This method is provided to fit the chained-object pattern
that will be used by the rest of the Doodle API.
Additionally, while a shallow copy is enough for most
cases, it will be possible for child classes to override
this.
"""
new = copy.copy(self)
new._register()
return new
# Setters ####################### # Setters #######################
def color(self, color: tuple[int, int, int]) -> "Doodle": # As noted in the design documentation, the decision
# was made to have setters be the name of the attribute.
#
# These modify & return the object.
#
# All setters (and modifiers) must return self.
def color(self, color: tuple[int, int, int]) -> Self:
""" """
Color works as a kind of setter function. Color works as a kind of setter function.
@ -100,7 +153,7 @@ class Doodle(ABC):
self._color = color self._color = color
return self return self
def pos(self, x: float, y: float) -> "Doodle": def pos(self, x: float, y: float) -> Self:
""" """
Another setter, just like color. Another setter, just like color.
@ -109,26 +162,28 @@ class Doodle(ABC):
self._pos_vec = (x, y) self._pos_vec = (x, y)
return self return self
def x(self, x: float) -> "Doodle": def x(self, x: float) -> Self:
""" """
Setter for x component. Setter for x component.
""" """
self._pos_vec = (x, self._pos_vec[1]) self._pos_vec = (x, self._pos_vec[1])
return self
def y(self, y: float) -> "Doodle": def y(self, y: float) -> Self:
""" """
Setter for x component. Setter for x component.
""" """
self._pos_vec = (self._pos_vec[0], y) self._pos_vec = (self._pos_vec[0], y)
return self
def alpha(self, a: int) -> "Doodle": def alpha(self, a: int) -> Self:
""" """
Setter for alpha transparency Setter for alpha transparency
""" """
self._alpha = a self._alpha = a
return self return self
def z(self, z: float) -> "Doodle": def z(self, z: float) -> Self:
""" """
Setter for z_index Setter for z_index
""" """
@ -137,7 +192,13 @@ class Doodle(ABC):
# Modifiers ################# # Modifiers #################
def move(self, dx: float, dy: float) -> "Doodle": # These modify properties like setters, but allow
# multiple operations to be done in a single call.
#
# They can also take advantage of knowledge of current
# state, like `move` which applies a delta to the position.
def move(self, dx: float, dy: float) -> Self:
""" """
This shifts the vector by a set amount. This shifts the vector by a set amount.
@ -147,7 +208,7 @@ class Doodle(ABC):
""" """
return self.pos(self._pos_vec[0] + dx, self._pos_vec[1] + dy) return self.pos(self._pos_vec[0] + dx, self._pos_vec[1] + dy)
def random(self) -> "Doodle": def random(self) -> Self:
""" """
Randomize the position and color. Randomize the position and color.
""" """
@ -168,9 +229,12 @@ class Doodle(ABC):
return the screen position derived from the parent position return the screen position derived from the parent position
plus the current object's x component. plus the current object's x component.
If a parent object is at (100, 100) and the child is at (10, 10)
that should come through as (110, 110) in world coordinates.
Note the recursion here, parent.world_x is an instance of doodle.world_x. Note the recursion here, parent.world_x is an instance of doodle.world_x.
For example: For another example:
A x = 100 A x = 100
|--------B x = 10 |--------B x = 10
@ -203,12 +267,19 @@ class Doodle(ABC):
@property @property
def rgba(self) -> (int, int, int, int): def rgba(self) -> (int, int, int, int):
""" """
Access for color+alpha, used by draw functions
which need a 4-tuple.
""" """
return (*self._color, self._alpha) return (*self._color, self._alpha)
class Group(Doodle): class Group(Doodle):
""" """
A concrete-in-implementation, abstract-in-concept doodle.
A group is merely a list of other doodles, allowing
doodles to be arranged/moved/updated in a tree-like manner.
For now, only Group objects can have child doodles. For now, only Group objects can have child doodles.
It may be desirable to let any object serve as a parent It may be desirable to let any object serve as a parent
but for now, groups are needed. but for now, groups are needed.
@ -225,71 +296,104 @@ class Group(Doodle):
""" """
def __init__(self, parent=None): def __init__(self, parent=None):
# Like all constructors derived from doodle, this constructor
# initializes the parent Doodle class, and then adds its own
# additional attributse.
super().__init__(parent) super().__init__(parent)
self._doodles = [] self._doodles = []
def __repr__(self): def __repr__(self):
return f"Group(pos={self.world_vec}, doodles={len(self._doodles)})" return f"Group(pos={self.world_vec}, doodles={len(self._doodles)})"
def draw(self): def draw(self) -> None:
""" """
Groups, despite being an abstract concept, are drawable. Groups, despite being an abstract concept, are drawable.
To draw a group is to draw everything in it. To draw a group is to draw everything in it.
This is done by default, since all drawables will be This is done by default, since all drawables will be
registered with the scene upon creation. registered with the scene upon creation.
Thus the *complete* implementation of this function
is to "pass", the drawing will be handled by the child
doodles.
(If they weren't already registered with the world singleton
this would loop over all child doodles and draw them).
""" """
pass pass
def copy(self) -> "Group": def copy(self) -> "Group":
""" """
An override. An override of copy that handles the special
case of having a mutable list of Doodles
We are storing a list, so deep copies are necessary. as an attribute.
""" """
new = copy.copy(self) # still a shallow copy of base data since
new._register() # we're going to overwrite _doodles separately
new = super().copy()
new._doodles = [] new._doodles = []
for child in self._doodles: for child in self._doodles:
# this code happens outside of Doodle.copy so that
# the child is never accidentally registered with
# the old parent
child = copy.copy(child) child = copy.copy(child)
child._parent = new child._parent = new
child._register() child._register()
return new return new
def color(self, color: tuple[int, int, int]) -> "Doodle": def color(self, color: tuple[int, int, int]) -> Doodle:
""" """
Another override. An override of Doodle.color.
Nothing will ever be drawn in the parent What _color should do on Groups is a bit ambiguous.
color, but we do want to have the set
cascade down to child objects.
We don't cascade pos() calls, why not? The way parent-child relationships work here means
this color will never be used directly.
Instead the decision was made that child objects
will be colored by a call to this, and new
children will be colored the color set on the parent
group when added.
Subsequent calls to color on child elements will recolor them
allowing a group to have different colors within it.
The position is handled differently (see world_x)
since a moving parent should always move the children.
""" """
super().color(color) super().color(color)
for d in self._doodles: for d in self._doodles:
d.color(color) d.color(color)
return self return self
def add(self, doodle: "Doodle") -> "Group": def add(self, doodle: Doodle) -> Self:
""" """
The only unique method of this class, allowing us The only unique method of this class, allowing us
to add objects to the group. to add objects to the group.
Note the violation of class boundaries here. A simple implementation, but note that we set doodle._parent.
This assignment is, strictly speaking, a violation of class
boundaries. Sometimes two classes work together
in a way that makes this necessary, as noted above.
In some languages this would be done via a "protected"
attribute, which is a status between public and private
that only lets certain classes access internals.
In Python, _parent is merely a suggestion,
and in this case, it is a suggestion that we can safely
ignore.
These classes are tightly coupled to one another,
hence their implementations living side-by-side and
being allowed to peek at one another's internals.
The alternative would be to have a set_parent() method
but since we want groups to add children and not vice-versa
there's no reason to add set_parent() to Doodle since
it shouldn't be called by anything other than Group.
So in effect, we're ensuring the encapsulation of Doodle's
behavior everywhere else by breaking that rule here.
""" """
self._doodles.append(doodle) self._doodles.append(doodle)
doodle._parent = self doodle._parent = self
# This assignment is, strictly speaking, a violation of class
# boundaries. Sometimes two classes work together
# in a way that makes this necessary, as noted above.
# In some languages this would be done via a "protected"
# attribute, which is a status between public and private
# that only lets certain classes access internals.
#
# In Python, _parent is merely a suggestion,
# and since it is likely that the same author wrote both
# classes, it is a suggestion that we can safely ignore
# if we understand the implications of tightly
# binding the implementations of these two classes.
return self return self

View File

@ -2,22 +2,76 @@ import abc
class DrawEngine(abc.ABC): class DrawEngine(abc.ABC):
"""
This is an abstract class that defines the methods needed
to have a drawing backend.
This interface was *extracted* not designed.
The first version of this library had a hard dependency on pygame
for drawing. Each shape had an overriden draw method that called
pygame functions directly.
The refactor in https://github.com/jamesturk/doodles/pull/1
pulled this out by searching the code for all pygame references
and extracting them into their own class. `PygameDrawEngine`.
These are the signatures of that classes methods, so that
a new implementation (to draw in OpenGL, or the browser, or on PDFs)
could provide the necessary implementations of these functions.
Note that starting with these as embedded and then extracting them
is a perfectly valid way to get here, you could also be diligent
while writing in the first place, and ensure that code needing
isolation (such as a library you want to avoid tight coupling to)
only is added to a specific class or module.
TODO: this got behind the current impl
"""
@abc.abstractmethod @abc.abstractmethod
def init(self): def init(self):
pass """
Called once, provides a place for the backend to initialize
itself as needed.
"""
@abc.abstractmethod @abc.abstractmethod
def render(self, background_color: "Color", drawables: list["Doodle"]): def render(self, background_color: "Color", drawables: list["Doodle"]):
pass """
Workhorse function, should set background and then draw all Doodles.
Will be called once per frame.
"""
@abc.abstractmethod @abc.abstractmethod
def circle_draw(self, screen): def circle_draw(self, circle: "Circle"):
pass """
Method to draw a Circle obj.
"""
@abc.abstractmethod @abc.abstractmethod
def rect_draw(self, screen): def rect_draw(self, rect: "Rect"):
pass """
Method to draw a Rectangle obj.
"""
@abc.abstractmethod @abc.abstractmethod
def line_draw(self, screen): def line_draw(self, line: "Line"):
pass """
Method to draw a Line obj.
"""
@abc.abstractmethod
def text_render(self, text: "Text"):
"""
Method to pre-render a text object.
"""
@abc.abstractmethod
def text_draw(self, text: "Text"):
"""
Method to draw a pre-rendered text object.
"""
# TODO: should make_font/get_font become part of the
# reqwuired interface?

View File

@ -1,8 +1,24 @@
"""
Experimental ideas for defining layouts.
"""
def make_grid(iterable, cols, rows, width, height, *, x_offset=0, y_offset=0): def make_grid(iterable, cols, rows, width, height, *, x_offset=0, y_offset=0):
""" """
Arranges the objects in iterable in a grid with the given parameters. This function attempts to create an evenly-spaced grid of drawables.
The drawables are provided in an iterable, and when the iterable
runs out of items the grid stops.
A lot more behavior could be offered here, but this was
done to validate the concept.
By using an iterable here, it is possible to pass in lists that are
too big or too small, and the grid will still work.
(Leaving empty slots empty, or not drawing extras depending on the
relationship of the grid size to the iterable.)
Another neat trick is using an infinite generator (as done in examples/grid)
since only as many doodles as needed to fill the grid will be gathered.
""" """
try: try:
for c in range(cols): for c in range(cols):

View File

@ -1,3 +1,9 @@
"""
Class to draw lines.
This is the most well-documented version of a concrete doodle,
the easiest to learn from.
"""
import math import math
import random import random
from typing import Callable from typing import Callable
@ -6,6 +12,11 @@ from .world import world
class Line(Doodle): class Line(Doodle):
# Adds one attribute to Doodle: the distance/offset vector
# from the position. Together these form the end points of the
# line.
_offset_vec: tuple[float, float]
def __init__(self, parent=None): def __init__(self, parent=None):
""" """
We keep the same interface as Doodle, to follow the Liskov substitution We keep the same interface as Doodle, to follow the Liskov substitution
@ -26,23 +37,19 @@ class Line(Doodle):
""" """
Implementation of the abstract draw function for the line. Implementation of the abstract draw function for the line.
Note: This is a classic violation of single responsibility. This class passes the responsibility of actually drawing to
the world.draw_engine, which is designed to be configurable.
Instead, you could imagine a class like: An earlier version had Pygame drawing code in here, but that
violated the Single Responsibility Principle.
class DrawingBackend: Instead, as a pass-through, the actual drawing logic is not coupled
def draw_doodle(doodle_type, doodle): ... to the mathematical representation of a line.
class PygameBackend(DrawingBackend):
def draw_line(...): ...
This would make it possible to attach different
drawing backends, restoring single-responsibility
to the class and gaining flexibility from separating
presentation logic from data manipulation.
""" """
world.draw_engine.line_draw(self) world.draw_engine.line_draw(self)
## Setters / Modifiers / Getters ##############
def to(self, x: float, y: float) -> "Doodle": def to(self, x: float, y: float) -> "Doodle":
""" """
A setter for the line's offset vector. A setter for the line's offset vector.
@ -58,7 +65,12 @@ class Line(Doodle):
def vec(self, degrees: float, magnitude: float): def vec(self, degrees: float, magnitude: float):
""" """
Alternate constructor, to create offset vector from angle & length. Alternate setter, to create offset vector from angle & length.
This is similar to the constructor/alternate constructor concept
where there's a base constructor that sets the propertries
directly (`to`), but there is also an alternate option
that handles commonly used case.
""" """
if isinstance(degrees, Callable): if isinstance(degrees, Callable):
self.register_update( self.register_update(
@ -75,9 +87,10 @@ class Line(Doodle):
def random(self) -> "Doodle": def random(self) -> "Doodle":
""" """
Overrides the parent's random, by extending the behavior. Overrides the parent's random, since a random line
also needs to have a offset vector.
This is an example of the open/closed principle. This is an example of the **Open/Closed Principle**.
We aren't modifying the parent classes' random function We aren't modifying the parent classes' random function
since doing so would be fragile and break if the since doing so would be fragile and break if the
parent class added more options. parent class added more options.
@ -93,7 +106,8 @@ class Line(Doodle):
@property @property
def end_vec(self): def end_vec(self):
""" """
Parallel to world_vec for end of line. Line goes from world_x, world_y to this position which
results from adding (world_x, world_y) + (offset_x, offset_y).
""" """
return ( return (
self.world_x + self._offset_vec[0], self.world_x + self._offset_vec[0],

View File

@ -1,3 +1,16 @@
"""
Entrypoint for running doodles.
Understanding this module is not important to undertanding
the architecture.
The most interesting thing here is that it dynamically loads
a module based on name, using `importlib`.
This file also currently contains a hard dependency on pygame,
it probably makes sense to factor this out too so that the
underlying library is swappable, not just the drawing code.
"""
import sys import sys
from pathlib import Path from pathlib import Path
import pygame import pygame
@ -6,14 +19,25 @@ import typer
from .world import world from .world import world
def get_examples(): def get_examples() -> list[str]:
"""
Looks in the doodles/examples directory and gets a list of modules.
"""
module_path = Path(__file__).parent / "examples" module_path = Path(__file__).parent / "examples"
submodules = [ submodules = [
file.stem for file in module_path.glob("*.py") if file.name != "__init__.py" file.stem for file in module_path.glob("*.py") if file.name != "__init__.py"
] ]
return submodules return submodules
def load_module(modname):
def load_module(modname: str):
"""
Loads a module by name (either by absolute path or from within
examples).
Once loaded, the module's `create()` function is called, which should
create instances of all drawable objects.
"""
pygame.display.set_caption("doodles: " + modname) pygame.display.set_caption("doodles: " + modname)
world.clear() world.clear()
try: try:
@ -31,8 +55,16 @@ def load_module(modname):
def main(modname: str = None): def main(modname: str = None):
"""
Entrypoint method.
Loads a module then runs the core app loop.
"""
# initalize world and underlying frameworks
world.init() world.init()
# get list of all examples and load first one if no name given
examples = get_examples() examples = get_examples()
ex_index = 0 ex_index = 0
if modname: if modname:
@ -40,7 +72,11 @@ def main(modname: str = None):
else: else:
load_module(examples[ex_index]) load_module(examples[ex_index])
# run until the application is quit
while True: while True:
# this is a pygame event loop, it monitors which keys are pressed
# and changes the example if left/right are pressed
for event in pygame.event.get(): for event in pygame.event.get():
if event.type == pygame.QUIT: if event.type == pygame.QUIT:
pygame.quit() pygame.quit()
@ -52,7 +88,10 @@ def main(modname: str = None):
elif event.key == pygame.K_LEFT: elif event.key == pygame.K_LEFT:
ex_index = (ex_index - 1) % len(examples) ex_index = (ex_index - 1) % len(examples)
load_module(examples[ex_index]) load_module(examples[ex_index])
# update the world animations and draw
world.update() world.update()
if __name__ == "__main__": if __name__ == "__main__":
typer.run(main) typer.run(main)

View File

@ -1,3 +1,10 @@
"""
Adds more drawables, like `lines`. For the most part
these classes only differ from `Line` in implementation.
The interface & decisions are the same but specific
to `Circle` and `Rectangle`.
"""
import random import random
from .doodles import Doodle from .doodles import Doodle
from .world import world from .world import world
@ -5,9 +12,6 @@ from .world import world
class Circle(Doodle): class Circle(Doodle):
def __init__(self, parent=None): def __init__(self, parent=None):
"""
This is a less interesting class than Line, but very similar.
"""
super().__init__(parent) super().__init__(parent)
# circle is a position & radius # circle is a position & radius
self._radius = 0 self._radius = 0
@ -60,22 +64,19 @@ class Rectangle(Doodle):
def width(self, w: float) -> "Doodle": def width(self, w: float) -> "Doodle":
""" """
A setter for the width Set new width.
""" """
self._width = w self._width = w
return self return self
def height(self, h: float) -> "Doodle": def height(self, h: float) -> "Doodle":
""" """
A setter for the height Set new height.
""" """
self._height = h self._height = h
return self return self
def grow(self, dw: float, dh: float): def grow(self, dw: float, dh: float):
"""
Modify radius by an amount. (Negative to shrink.)
"""
return self.width(self._w + dw).height(self._h + dh) return self.width(self._w + dw).height(self._h + dh)
def random(self, upper=100) -> "Doodle": def random(self, upper=100) -> "Doodle":

View File

@ -1,3 +1,21 @@
"""
This is the most complicated Doodle-derived class.
Like shapes, the interface is mostly identical to Line.
If you are mainly trying to understand the interfaces it is
safe to skip this one.
There is some more complexity here because fonts need to be
pre-rendered. This means that when `text` is called, an internal
cached copy of the font already drawn to a temporary piece of memory
"surface" in graphics programming parlance.
This is an example of a type of class where when a property changes
some additional computation can be done to precompute/cache
some expensive logic.
Look at _render() for more.
"""
import pygame import pygame
from .doodles import Doodle from .doodles import Doodle
from .world import world from .world import world
@ -6,7 +24,15 @@ from .world import world
class Text(Doodle): class Text(Doodle):
def __init__(self, parent=None): def __init__(self, parent=None):
""" """
Text will be centered at `pos` A text object stores a reference to a pre-loaded font,
the text to be drawn, and an internal `_rendered` object
that *can* be used to store the cached text.
Not all implementations will require pre-rendering, but
Pygame (the first implementation) does, so it is necessary
for the interface as written here.
When drawn, text will be centered at `pos`
""" """
super().__init__(parent) super().__init__(parent)
self._text = "" self._text = ""
@ -35,10 +61,14 @@ class Text(Doodle):
This function needs to set the _rendered property. This function needs to set the _rendered property.
_rendered may be relied upon by draw_engine.draw_text. _rendered may be relied upon by draw_engine.draw_text.
Text needs to be rendered once on change to be performant.
Doing this in draw would be much slower since it is called
much more often than the text changes.
(draw called ~60 times per second, _render only called when
text is updated.)
""" """
# text needs to be rendered once on change to be performant
# doing this in draw would be much slower since it is called
# much more often than the text changes
if not self._font: if not self._font:
self._font = world.draw_engine.get_font() # default font self._font = world.draw_engine.get_font() # default font
self._rendered = world.draw_engine.text_render( self._rendered = world.draw_engine.text_render(

View File

@ -1,11 +1,13 @@
"""
This is the most complex/implementation specific module.
TODO: this should be split into two modules once there
is a non-Pygame implementation.
"""
from .color import Color from .color import Color
import pygame import pygame
# TODO: fix this with a dynamic load
from .draw_engine import DrawEngine from .draw_engine import DrawEngine
# TODO: make configurable
DEFAULT_FONT_SIZE = 24
class PygameDrawEngine(DrawEngine): class PygameDrawEngine(DrawEngine):
# Having each bit of text on the screen load a separate copy # Having each bit of text on the screen load a separate copy
@ -22,38 +24,32 @@ class PygameDrawEngine(DrawEngine):
# note that here, once a font is loaded it does not change. # note that here, once a font is loaded it does not change.
# This avoids nearly all pitfalls associated with this approach. # This avoids nearly all pitfalls associated with this approach.
_fonts: dict[str, pygame.font.Font] = {} _fonts: dict[str, pygame.font.Font] = {}
DEFAULT_FONT_SIZE = 24
# this method is attached to the class `Text`, not individual instances # Required Interface Methods ##############
# like normal methods (which take self as their implicit parameter)
@classmethod
def make_font(cls, name, size, font=None, bold=False, italic=False):
"""
The way fonts work in most graphics libraries requires choosing a font
size, as well as any variation (bold, italic) at the time of creation.
It would be nice if we could allow individual Text objects vary these,
but doing so would be much more complex or require significantly more
memory.
"""
if font is None:
font = pygame.font.Font(None, size)
else:
path = pygame.font.match_font(font, bold=bold, italic=italic)
font = pygame.font.Font(path, size)
cls._fonts[name] = font
@classmethod
def get_font(cls, name=None):
if not name:
# None -> default font
# load on demand
if None not in cls._fonts:
cls._fonts[None] = pygame.font.Font(None, DEFAULT_FONT_SIZE)
return cls._fonts[None]
else:
return cls._fonts[name]
def init(self): def init(self):
"""
This is a deferred-constructor of sorts.
We don't do this work in `__init__` since we have less control
over when the object is created than when we actually want to do
the platform-specific initialization.
For example, by the point this is called, we need to have called
`pygame.init()`, but that would require us know that, and only construct
the object after it was called, ex:
# this would break
engine = PygameDrawEngine()
pygame.init()
# this would work
pygame.init()
engine = PygameDrawEngine()
This isn't a perfect solution to that problem, but a fair compromise for now.
"""
self.screen = pygame.display.set_mode((world.WIDTH, world.HEIGHT)) self.screen = pygame.display.set_mode((world.WIDTH, world.HEIGHT))
self.buffer = pygame.Surface((world.WIDTH, world.HEIGHT), pygame.SRCALPHA) self.buffer = pygame.Surface((world.WIDTH, world.HEIGHT), pygame.SRCALPHA)
@ -87,8 +83,7 @@ class PygameDrawEngine(DrawEngine):
pygame.draw.aaline(self.buffer, ll.rgba, ll.world_vec, ll.end_vec) pygame.draw.aaline(self.buffer, ll.rgba, ll.world_vec, ll.end_vec)
def text_render(self, text: str, font: str, color: Color) -> "TODO": def text_render(self, text: str, font: str, color: Color) -> "TODO":
""" returns an intermediated RenderedText """ """returns an intermediated RenderedText"""
# TODO: add accessor text_val
return font.render(text, True, color) return font.render(text, True, color)
def text_draw(self, txt: "Text"): def text_draw(self, txt: "Text"):
@ -96,6 +91,35 @@ class PygameDrawEngine(DrawEngine):
text_rect = txt._rendered.get_rect(center=txt.world_vec) text_rect = txt._rendered.get_rect(center=txt.world_vec)
self.buffer.blit(txt._rendered, text_rect) self.buffer.blit(txt._rendered, text_rect)
def make_font(self, name, size, font=None, bold=False, italic=False):
"""
The way fonts work in most graphics libraries requires choosing a font
size, as well as any variation (bold, italic) at the time of creation.
It would be nice if we could allow individual Text objects vary these,
but doing so would be much more complex or require significantly more
memory.
"""
if font is None:
font = pygame.font.Font(None, size)
else:
path = pygame.font.match_font(font, bold=bold, italic=italic)
font = pygame.font.Font(path, size)
self._fonts[name] = font
def get_font(self, name=None):
"""
Load a font by name, if no name is given, use the default font.
"""
if not name:
# None -> default font
# load on demand
if None not in self._fonts:
self._fonts[None] = pygame.font.Font(None, self.DEFAULT_FONT_SIZE)
return self._fonts[None]
else:
return self._fonts[name]
class World: class World:
""" """
@ -181,6 +205,5 @@ class World:
self.draw_engine.render(self.background_color, self._drawables) self.draw_engine.render(self.background_color, self._drawables)
# our singleton instance # our singleton instance
world = World() world = World()