From dac0de86dd4b75da1697b80569c1c29f052ef79d Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sun, 9 Aug 2026 19:40:40 -0600 Subject: [PATCH 1/2] docs: document the public Graphik API and drawText's center anchoring Adds a docstring to the Graphik class and to every public method, covering the parameter contract a caller needs: coordinate anchoring, units, the repeat-fire semantics of drawButton's callback, and drawImage's path-keyed caching and raise behavior. help(Graphik) and IDE tooltips previously showed nothing, which matters most for the vendored copies of graphik.py that consumers read without the README alongside. Records in the README that drawText anchors on the center of the rendered text while drawRectangle, drawButton and drawImage anchor on their top-left corner, and pins that behavior with a test so the documented claim is enforced rather than asserted. The existing implementation comments are left untouched; they answer a maintainer's question, not a caller's. No behavior, signature or version change, so no consumer impact. Closes #32 Closes #33 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 +- .../python/preponderous/graphik/graphik.py | 91 +++++++++++++++++++ .../preponderous/graphik/test_graphik.py | 33 +++++++ 3 files changed, 126 insertions(+), 2 deletions(-) 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..73a3d5b 100644 --- a/src/main/python/preponderous/graphik/graphik.py +++ b/src/main/python/preponderous/graphik/graphik.py @@ -6,6 +6,25 @@ # @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 +33,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 +60,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 +116,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 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 +136,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 +172,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..14b4e74 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 = [] From 1c58751bc1cc1e3fc2ffd664f45874d952029216 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sun, 9 Aug 2026 19:42:57 -0600 Subject: [PATCH 2/2] docs: address self-review findings on the API docstrings Drops the doctest prompts from the class example -- the snippet is illustrative, and `>>> pygame.init()` would fail if doctest collection were ever switched on, since init() returns a tuple the example does not show. Lists drawButton alongside drawRectangle and drawImage in drawText's anchoring note, matching the class docstring and the README rather than naming only two of the three top-left-anchored siblings. Renames a loop variable in the new anchoring test for symmetry with the line above it. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/python/preponderous/graphik/graphik.py | 16 +++++++++------- .../python/preponderous/graphik/test_graphik.py | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/python/preponderous/graphik/graphik.py b/src/main/python/preponderous/graphik/graphik.py index 73a3d5b..cb7cd2d 100644 --- a/src/main/python/preponderous/graphik/graphik.py +++ b/src/main/python/preponderous/graphik/graphik.py @@ -17,12 +17,14 @@ class Graphik: 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) + 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. @@ -119,7 +121,7 @@ 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 and drawImage. + its top-left corner as in drawRectangle, drawButton and drawImage. Args: text: The string to render. diff --git a/src/test/python/preponderous/graphik/test_graphik.py b/src/test/python/preponderous/graphik/test_graphik.py index 14b4e74..2d55c7c 100644 --- a/src/test/python/preponderous/graphik/test_graphik.py +++ b/src/test/python/preponderous/graphik/test_graphik.py @@ -265,7 +265,7 @@ def test_draw_text_centers_the_text_on_the_given_position(): assert inked, "drawText left the surface untouched" xs = [x for x, _ in inked] - ys = [_y for _, _y 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.