diff --git a/src/doodles/__init__.py b/src/doodles/__init__.py index 82114e2..9e520b5 100644 --- a/src/doodles/__init__.py +++ b/src/doodles/__init__.py @@ -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 .lines import Line from .shapes import Circle, Rectangle from .color import Color from .text import Text + __all__ = [Doodle, Group, Line, Circle, Rectangle, Color, Text] diff --git a/src/doodles/color.py b/src/doodles/color.py index 22a8f5b..2f17c16 100644 --- a/src/doodles/color.py +++ b/src/doodles/color.py @@ -7,8 +7,15 @@ class Color: 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. - Instead it will be used like Color.BLACK + There is no reason for anyone to ever declare an instance of this class. + 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 """ @@ -30,6 +37,9 @@ class Color: PINK = (255, 119, 168) LIGHT_PEACH = (255, 204, 170) + def __init__(self): + raise NotImplementedError("Color is not meant to be invoked directly") + @staticmethod def all(): colors = list(Color.__dict__.values()) @@ -38,10 +48,3 @@ class Color: @staticmethod def random(): 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. diff --git a/src/doodles/doodles.py b/src/doodles/doodles.py index 060d3ac..9aba6a8 100644 --- a/src/doodles/doodles.py +++ b/src/doodles/doodles.py @@ -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 copy from abc import ABC, abstractmethod +from typing import Callable, Self from .color import Color from .world import world @@ -17,18 +30,35 @@ class Doodle(ABC): 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) - that point would appear at (110, 110). + that point would appear ag (110, 110). Careful attention is paid in this inheritance hierarchy to the 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): + # 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._updates = [] self._color = parent._color if parent else Color.BLACK self._alpha = parent._alpha if parent else 255 self._z_index = 0 + + # Design Note: # Is storing this vector in a tuple the right thing to do? # It might make more sense to store _x and _y, or use # a library's optimized 2D vector implementation. @@ -36,12 +66,19 @@ class Doodle(ABC): # All references to _pos_vec are internal to the class, # so it will be trivial to swap this out later. self._pos_vec = (0, 0) + self._register() 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: - # register with parent for updates self._parent.add(self) world.add(self) @@ -57,6 +94,31 @@ class Doodle(ABC): """ 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): self._updates.append((method, args)) @@ -72,25 +134,16 @@ class Doodle(ABC): evaled_args = [arg() for arg in 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 ####################### - 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. @@ -100,7 +153,7 @@ class Doodle(ABC): self._color = color return self - def pos(self, x: float, y: float) -> "Doodle": + def pos(self, x: float, y: float) -> Self: """ Another setter, just like color. @@ -109,26 +162,28 @@ class Doodle(ABC): self._pos_vec = (x, y) return self - def x(self, x: float) -> "Doodle": + def x(self, x: float) -> Self: """ Setter for x component. """ 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. """ 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 """ self._alpha = a return self - def z(self, z: float) -> "Doodle": + def z(self, z: float) -> Self: """ Setter for z_index """ @@ -137,7 +192,13 @@ class Doodle(ABC): # 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. @@ -147,7 +208,7 @@ class Doodle(ABC): """ 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. """ @@ -168,9 +229,12 @@ class Doodle(ABC): return the screen position derived from the parent position 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. - For example: + For another example: A x = 100 |--------B x = 10 @@ -203,12 +267,19 @@ class Doodle(ABC): @property 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) 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. It may be desirable to let any object serve as a parent but for now, groups are needed. @@ -225,71 +296,104 @@ class Group(Doodle): """ 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) self._doodles = [] def __repr__(self): 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. To draw a group is to draw everything in it. This is done by default, since all drawables will be 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 def copy(self) -> "Group": """ - An override. - - We are storing a list, so deep copies are necessary. + An override of copy that handles the special + case of having a mutable list of Doodles + as an attribute. """ - new = copy.copy(self) - new._register() + # still a shallow copy of base data since + # we're going to overwrite _doodles separately + new = super().copy() new._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._parent = new child._register() 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 - color, but we do want to have the set - cascade down to child objects. + What _color should do on Groups is a bit ambiguous. - 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) for d in self._doodles: d.color(color) return self - def add(self, doodle: "Doodle") -> "Group": + def add(self, doodle: Doodle) -> Self: """ The only unique method of this class, allowing us 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) 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 diff --git a/src/doodles/draw_engine.py b/src/doodles/draw_engine.py index b7908cc..ca31992 100644 --- a/src/doodles/draw_engine.py +++ b/src/doodles/draw_engine.py @@ -2,22 +2,76 @@ import 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 def init(self): - pass + """ + Called once, provides a place for the backend to initialize + itself as needed. + """ @abc.abstractmethod 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 - def circle_draw(self, screen): - pass + def circle_draw(self, circle: "Circle"): + """ + Method to draw a Circle obj. + """ @abc.abstractmethod - def rect_draw(self, screen): - pass + def rect_draw(self, rect: "Rect"): + """ + Method to draw a Rectangle obj. + """ @abc.abstractmethod - def line_draw(self, screen): - pass + def line_draw(self, line: "Line"): + """ + 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? diff --git a/src/doodles/layouts.py b/src/doodles/layouts.py index 1287d95..dadcca5 100644 --- a/src/doodles/layouts.py +++ b/src/doodles/layouts.py @@ -1,8 +1,24 @@ - +""" +Experimental ideas for defining layouts. +""" 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: for c in range(cols): diff --git a/src/doodles/lines.py b/src/doodles/lines.py index 42ea444..501802a 100644 --- a/src/doodles/lines.py +++ b/src/doodles/lines.py @@ -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 random from typing import Callable @@ -6,6 +12,11 @@ from .world import world 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): """ 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. - 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: - def draw_doodle(doodle_type, doodle): ... - - 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. + Instead, as a pass-through, the actual drawing logic is not coupled + to the mathematical representation of a line. """ world.draw_engine.line_draw(self) + ## Setters / Modifiers / Getters ############## + def to(self, x: float, y: float) -> "Doodle": """ A setter for the line's offset vector. @@ -58,7 +65,12 @@ class Line(Doodle): 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): self.register_update( @@ -75,9 +87,10 @@ class Line(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 since doing so would be fragile and break if the parent class added more options. @@ -93,7 +106,8 @@ class Line(Doodle): @property 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 ( self.world_x + self._offset_vec[0], diff --git a/src/doodles/main.py b/src/doodles/main.py index 382a79b..a3bc614 100644 --- a/src/doodles/main.py +++ b/src/doodles/main.py @@ -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 from pathlib import Path import pygame @@ -6,14 +19,25 @@ import typer 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" submodules = [ file.stem for file in module_path.glob("*.py") if file.name != "__init__.py" ] 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) world.clear() try: @@ -31,8 +55,16 @@ def load_module(modname): def main(modname: str = None): + """ + Entrypoint method. + + Loads a module then runs the core app loop. + """ + + # initalize world and underlying frameworks world.init() + # get list of all examples and load first one if no name given examples = get_examples() ex_index = 0 if modname: @@ -40,7 +72,11 @@ def main(modname: str = None): else: load_module(examples[ex_index]) + # run until the application is quit 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(): if event.type == pygame.QUIT: pygame.quit() @@ -52,7 +88,10 @@ def main(modname: str = None): elif event.key == pygame.K_LEFT: ex_index = (ex_index - 1) % len(examples) load_module(examples[ex_index]) + + # update the world animations and draw world.update() + if __name__ == "__main__": typer.run(main) diff --git a/src/doodles/shapes.py b/src/doodles/shapes.py index 426c908..2b973e1 100644 --- a/src/doodles/shapes.py +++ b/src/doodles/shapes.py @@ -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 from .doodles import Doodle from .world import world @@ -5,9 +12,6 @@ from .world import world class Circle(Doodle): def __init__(self, parent=None): - """ - This is a less interesting class than Line, but very similar. - """ super().__init__(parent) # circle is a position & radius self._radius = 0 @@ -60,22 +64,19 @@ class Rectangle(Doodle): def width(self, w: float) -> "Doodle": """ - A setter for the width + Set new width. """ self._width = w return self def height(self, h: float) -> "Doodle": """ - A setter for the height + Set new height. """ self._height = h return self 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) def random(self, upper=100) -> "Doodle": diff --git a/src/doodles/text.py b/src/doodles/text.py index f433043..e7fce92 100644 --- a/src/doodles/text.py +++ b/src/doodles/text.py @@ -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 from .doodles import Doodle from .world import world @@ -6,7 +24,15 @@ from .world import world class Text(Doodle): 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) self._text = "" @@ -35,10 +61,14 @@ class Text(Doodle): This function needs to set the _rendered property. _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: self._font = world.draw_engine.get_font() # default font self._rendered = world.draw_engine.text_render( diff --git a/src/doodles/world.py b/src/doodles/world.py index d8d9e6a..33137e9 100644 --- a/src/doodles/world.py +++ b/src/doodles/world.py @@ -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 import pygame -# TODO: fix this with a dynamic load from .draw_engine import DrawEngine -# TODO: make configurable -DEFAULT_FONT_SIZE = 24 - class PygameDrawEngine(DrawEngine): # 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. # This avoids nearly all pitfalls associated with this approach. _fonts: dict[str, pygame.font.Font] = {} + DEFAULT_FONT_SIZE = 24 - # this method is attached to the class `Text`, not individual instances - # 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] + # Required Interface Methods ############## 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.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) def text_render(self, text: str, font: str, color: Color) -> "TODO": - """ returns an intermediated RenderedText """ - # TODO: add accessor text_val + """returns an intermediated RenderedText""" return font.render(text, True, color) def text_draw(self, txt: "Text"): @@ -96,6 +91,35 @@ class PygameDrawEngine(DrawEngine): text_rect = txt._rendered.get_rect(center=txt.world_vec) 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: """ @@ -181,6 +205,5 @@ class World: self.draw_engine.render(self.background_color, self._drawables) - # our singleton instance world = World()