diff --git a/README.md b/README.md index 7948e4d..5c864d6 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ Public `Graphik` methods: - `Graphik(gameDisplay=None)` — construct a helper bound to a pygame display surface (creates a default 900x600 window if none is given). - `getGameDisplay()` — returns the bound display surface. - `getVersion()` — returns the installed graphik version string. -- `drawRectangle(xpos, ypos, width, height, color)` -- `drawText(text, xpos, ypos, size, color)` +- `drawRectangle(xpos, ypos, width, height, color)` — `xpos`/`ypos` are the rectangle's top-left corner. +- `drawText(text, xpos, ypos, size, color)` — `xpos`/`ypos` are the **center** of the rendered text, not its top-left corner. This differs from `drawRectangle`, `drawButton` and `drawImage`, which all position their top-left corner at the given coordinates. - `drawButton(xpos, ypos, width, height, colorBox, colorText, sizeText, text, function)` — draws a rectangle and centered text, and calls `function()` on every call where the mouse is held inside the button with button 1 down (there is no click-edge detection, so a held-down mouse fires `function()` once per call, not once per click). Callers wanting once-per-click semantics must debounce on their side. - `drawImage(filePath, xpos, ypos, width, height)` — caches the loaded and scaled surface by `filePath`, so an image edited on disk mid-run will not be picked up until the process restarts. diff --git a/src/main/python/preponderous/graphik/graphik.py b/src/main/python/preponderous/graphik/graphik.py index 3b26d50..cb7cd2d 100644 --- a/src/main/python/preponderous/graphik/graphik.py +++ b/src/main/python/preponderous/graphik/graphik.py @@ -6,6 +6,27 @@ # @author Daniel McCoy Stephenson # @since February 3rd, 2022 class Graphik: + """Helper methods for drawing to a pygame display surface. + + Every draw method renders to the surface the instance was constructed + with, reachable through getGameDisplay(). Coordinates are in pixels and + measured from the top-left of that surface, and colors are ``(r, g, b)`` + tuples -- the constants below cover the common cases. + + Anchoring is not uniform across the draw methods: drawRectangle, + drawButton and drawImage position their top-left corner at the given + ``(xpos, ypos)``, while drawText centers the rendered text on it. + + Example:: + + import pygame + from preponderous.graphik import Graphik + + pygame.init() + graphik = Graphik(pygame.display.set_mode((900, 600))) + graphik.drawRectangle(100, 100, 200, 50, Graphik.blue) + """ + # Color constants, reachable as Graphik.white or instance.white, etc. black = (0, 0, 0) white = (255, 255, 255) @@ -14,6 +35,14 @@ class Graphik: blue = (0, 0, 200) def __init__(self, gameDisplay=None): + """Bind a Graphik to the surface it draws on. + + Args: + gameDisplay: The pygame surface every draw method renders to, + normally the one returned by ``pygame.display.set_mode``. + When omitted, a default 900x600 display is created, which + opens a window as a side effect. + """ # Consumers normally pass their own gameDisplay-backed surface. When # none is supplied, fall back to a default 900x600 window so the # no-argument Graphik() form works instead of raising. @@ -33,12 +62,27 @@ def __init__(self, gameDisplay=None): self._scaledImages = {} def getGameDisplay(self): + """Return the pygame surface this instance draws to.""" return self.gameDisplay def getVersion(self): + """Return the installed graphik version string. + + This is the same value as ``preponderous.graphik.__version__``, which + is reachable without constructing a Graphik (and so without a display). + """ return __version__ def drawRectangle(self, xpos, ypos, width, height, color): + """Fill a rectangle on the display. + + Args: + xpos: X coordinate of the rectangle's left edge, in pixels. + ypos: Y coordinate of the rectangle's top edge, in pixels. + width: Width of the rectangle, in pixels. + height: Height of the rectangle, in pixels. + color: Fill color as an ``(r, g, b)`` tuple. + """ pygame.draw.rect(self.gameDisplay, color, [xpos, ypos, width, height]) def _getFont(self, size): @@ -74,6 +118,19 @@ def _getFont(self, size): return self._fonts[size] def drawText(self, text, xpos, ypos, size, color): + """Render a line of text, centered on the given position. + + Note that ``(xpos, ypos)`` is the *center* of the rendered text, not + its top-left corner as in drawRectangle, drawButton and drawImage. + + Args: + text: The string to render. + xpos: X coordinate the text is centered on, in pixels. + ypos: Y coordinate the text is centered on, in pixels. + size: Font size in points. The font module is initialized on + demand, and one font per distinct size is cached and reused. + color: Text color as an ``(r, g, b)`` tuple. + """ myFont = self._getFont(size) textSurface = myFont.render(text, True, color) textRectangle = textSurface.get_rect() @@ -81,6 +138,24 @@ def drawText(self, text, xpos, ypos, size, color): self.gameDisplay.blit(textSurface, textRectangle) def drawButton(self, xpos, ypos, width, height, colorBox, colorText, sizeText, text, function): + """Draw a labelled box and call ``function`` while it is being clicked. + + ``function()`` is called on every invocation where the mouse sits + inside the box with button 1 held down -- once per call, not once per + click. A caller wanting once-per-click semantics must debounce on its + own side; see the implementation note below for why. + + Args: + xpos: X coordinate of the box's left edge, in pixels. + ypos: Y coordinate of the box's top edge, in pixels. + width: Width of the box, in pixels. + height: Height of the box, in pixels. + colorBox: Fill color of the box as an ``(r, g, b)`` tuple. + colorText: Color of the label as an ``(r, g, b)`` tuple. + sizeText: Font size of the label, in points. + text: The label, centered within the box. + function: Zero-argument callable invoked as described above. + """ # Polls the current mouse state rather than tracking press/release # edges, so function() fires on every call where the mouse is held # inside the button with button 1 down -- once per call, not once @@ -99,6 +174,24 @@ def drawButton(self, xpos, ypos, width, height, colorBox, colorText, sizeText, t function() def drawImage(self, filePath, xpos, ypos, width, height): + """Draw an image file, scaled to the given size. + + The loaded and scaled surfaces are cached against ``filePath``, so an + asset edited on disk mid-run is not picked up until the process + restarts. A path that fails to load caches nothing and raises on every + call. + + Args: + filePath: Path to the image file, used as the cache key. + xpos: X coordinate of the image's left edge, in pixels. + ypos: Y coordinate of the image's top edge, in pixels. + width: Width to scale the image to, in pixels. + height: Height to scale the image to, in pixels. + + Raises: + FileNotFoundError: If no file exists at ``filePath``. + pygame.error: If the file exists but pygame cannot decode it. + """ # Loading decodes the file from disk and scaling resamples it, both of # which cost far more than the blit() they exist to serve, so cache # both by filePath instead of redoing them every call. Unlike diff --git a/src/test/python/preponderous/graphik/test_graphik.py b/src/test/python/preponderous/graphik/test_graphik.py index 6712231..2d55c7c 100644 --- a/src/test/python/preponderous/graphik/test_graphik.py +++ b/src/test/python/preponderous/graphik/test_graphik.py @@ -245,6 +245,39 @@ def test_draw_text_blits_non_background_pixels(): assert changed +def test_draw_text_centers_the_text_on_the_given_position(): + # Pins the documented anchoring: drawText centers on (xpos, ypos), unlike + # drawRectangle/drawButton/drawImage, which put their top-left corner there. + graphik = _make_graphik((200, 100)) + display = graphik.getGameDisplay() + display.fill(Graphik.black) + + xpos, ypos = 100, 50 + graphik.drawText("WWWW", xpos, ypos, 20, Graphik.white) + + width, height = display.get_size() + inked = [ + (x, y) + for x in range(width) + for y in range(height) + if _rgb(display, (x, y)) != Graphik.black + ] + assert inked, "drawText left the surface untouched" + + xs = [x for x, _ in inked] + ys = [y for _, y in inked] + + # The ink straddles the requested point on all four sides -- a top-left + # anchor could never place ink above or to the left of it. + assert min(xs) < xpos < max(xs) + assert min(ys) < ypos < max(ys) + # And it is centered there, not merely overlapping it. Only the horizontal + # midpoint is asserted tightly: glyph ink is vertically asymmetric within + # the rendered rect (capital letters sit above the baseline), so the + # vertical ink midpoint is offset from the rect center that is centered. + assert abs((min(xs) + max(xs)) / 2 - xpos) <= 1 + + def _count_font_constructions(monkeypatch): # Wrap pygame.font.Font so tests can observe how often it is built. constructed = []