From ad0153a769e2bdc6d8efef9a8568504e23fb3b8e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 16:40:46 +0000 Subject: [PATCH 1/2] Document public pygraphics API for mkdocstrings RTD. Add Google-style docstrings on BMP565, ClipContext/ClippedCanvas, FrameBuffer properties, Draw/Font __init__, and related public methods; list format constants in the package docstring; export RGB888 in __all__. --- lib/pygraphics/__init__.py | 8 +++ lib/pygraphics/_bmp565.py | 64 +++++++++++++++++- lib/pygraphics/_clip.py | 107 ++++++++++++++++++++++++++++++- lib/pygraphics/_draw.py | 6 ++ lib/pygraphics/_files.py | 11 ++++ lib/pygraphics/_font.py | 23 +++++++ lib/pygraphics/_framebuf_plus.py | 31 +++++++++ 7 files changed, 247 insertions(+), 3 deletions(-) diff --git a/lib/pygraphics/__init__.py b/lib/pygraphics/__init__.py index 4155a8e..052e6d7 100644 --- a/lib/pygraphics/__init__.py +++ b/lib/pygraphics/__init__.py @@ -5,6 +5,13 @@ ``Area`` bounding boxes for partial updates. On CPython and CircuitPython the built-in pure-Python ``framebuf`` fallback is used automatically. +Framebuffer format constants (``FrameBuffer`` ``format`` argument): + +* ``MONO_VLSB``, ``MONO_HLSB``, ``MONO_HMSB`` — 1-bit monochrome +* ``GS2_HMSB``, ``GS4_HMSB``, ``GS8`` — 2-, 4-, and 8-bit greyscale +* ``RGB565`` — 16-bit color (MicroPython ``framebuf``) +* ``RGB888`` — 24-bit color (pygraphics extension) + Quick start:: import pygraphics @@ -75,6 +82,7 @@ def implementation(): "MONO_HMSB", "MONO_VLSB", "RGB565", + "RGB888", "Area", "ClipContext", "ClippedCanvas", diff --git a/lib/pygraphics/_bmp565.py b/lib/pygraphics/_bmp565.py index 852feba..5605703 100644 --- a/lib/pygraphics/_bmp565.py +++ b/lib/pygraphics/_bmp565.py @@ -77,11 +77,30 @@ def write_bmp565_file(f, buffer, width, height): class BMP565: - """Read a 16-bit RGB565 BMP as a sliceable, optionally streamed asset.""" + """Read a 16-bit RGB565 BMP as a sliceable, optionally streamed asset. + + Provide either ``filename`` (path to a BMP file) or ``source`` (an in-memory + RGB565 buffer with ``width`` and ``height``). + """ def __init__( self, filename=None, source=None, streamed=False, mirrored=False, width=None, height=None ): + """Load from a file path or an existing RGB565 buffer. + + Args: + filename: Path to an RGB565 BMP file. Mutually exclusive with ``source``. + source: Existing RGB565 pixel buffer. Requires ``width`` and ``height``. + streamed: If True with ``filename``, keep the file open and read rows + on demand instead of loading the full image into memory. + mirrored: When streaming, reverse each row read (rarely needed). + width: Pixel width when constructing from ``source``. + height: Pixel height when constructing from ``source``. + + Raises: + ValueError: If neither ``filename`` nor ``source`` is given, or the + file is not a valid RGB565 BMP. + """ self._filename = filename self._streamed = streamed self._mirrored = mirrored @@ -109,10 +128,24 @@ def __init__( self._mv = memoryview(self._buffer) def __call__(self, x, y, w, h): + """Return the RGB565 bytes for the rectangle at ``(x, y)`` size ``w`` × ``h``. + + Equivalent to ``self[x : x + w, y : y + h]``. + + Args: + x: Left edge in pixels. + y: Top edge in pixels. + w: Width in pixels. + h: Height in pixels. + + Returns: + Raw RGB565 bytes for the requested region. + """ return self[x : x + w, y : y + h] @property def buffer(self): + """Writable ``memoryview`` of the full RGB565 pixel buffer, or ``None`` if streamed.""" return self._mv @staticmethod @@ -124,6 +157,17 @@ def _exists(filename): return False def save(self, filename=None): + """Write this image as an RGB565 BMP file. + + If ``filename`` already exists, a numeric suffix is appended + (``image.bmp`` → ``image_1.bmp``, etc.). + + Args: + filename: Output path. Defaults to the source filename or ``image.bmp``. + + Returns: + The path actually written. + """ if filename is None: filename = self._filename if self._filename is not None else "image.bmp" while self._exists(filename): @@ -149,6 +193,22 @@ def _read_data(self, f): self._buffer = load_bmp565_buffer(f, self.width, self.height, self.data_offset) def __getitem__(self, key): + """Index or slice pixel data. + + Args: + key: One of: + + * ``(x, y)`` — single pixel as an ``int`` (RGB565) + * ``(x_slice, y_slice)`` — rectangular region as ``bytearray`` + * ``int`` — flat pixel index as an ``int`` + * ``slice`` — full-width row range as ``bytearray`` + + Returns: + Pixel value(s) for the requested key. + + Raises: + ValueError: If ``key`` is not a supported index form. + """ if isinstance(key, tuple): x, y = key if isinstance(x, slice) and isinstance(y, slice): @@ -188,11 +248,13 @@ def _get(self, start, stop): return out def deinit(self): + """Close the open file handle when constructed with ``streamed=True``.""" if self._streamed and self._file is not None: self._file.close() self._file = None def __exit__(self, exception_type, exception_value, traceback): + """Context-manager exit: call :meth:`deinit`.""" self.deinit() def __del__(self): diff --git a/lib/pygraphics/_clip.py b/lib/pygraphics/_clip.py index be194c3..5d7011c 100644 --- a/lib/pygraphics/_clip.py +++ b/lib/pygraphics/_clip.py @@ -31,9 +31,20 @@ def crop_rgb565_buffer(buf, src_w, src_x, src_y, crop_w, crop_h): class ClippedCanvas: - """Proxy that restricts drawing on ``canvas`` to ``clip``.""" + """Proxy that restricts drawing on ``canvas`` to ``clip``. + + Drawing methods that intersect the clip region are forwarded to the + underlying canvas; pixels outside the clip are ignored. Unknown attributes + fall through to ``canvas`` via ``__getattr__``. + """ def __init__(self, canvas, clip): + """Wrap ``canvas`` so drawing is limited to ``clip``. + + Args: + canvas: Framebuf-compatible draw target. + clip (Area): Inclusive rectangle in canvas coordinates. + """ self._canvas = canvas self._clip = clip self._graphics_clip = clip @@ -43,13 +54,26 @@ def __getattr__(self, name): @property def width(self): + """Underlying canvas width in pixels.""" return self._canvas.width @property def height(self): + """Underlying canvas height in pixels.""" return self._canvas.height def pixel(self, x, y, c=None): + """Get or set a pixel if ``(x, y)`` is inside the clip. + + Args: + x: X coordinate. + y: Y coordinate. + c: Color to set. If ``None``, read the existing pixel color. + + Returns: + On set: ``Area`` of the pixel, or ``None`` if outside the clip. + On get: pixel color, or ``None`` if outside the clip. + """ if not self._clip.contains(x, y): return None if c is None: @@ -58,9 +82,29 @@ def pixel(self, x, y, c=None): return Area(x, y, 1, 1) def fill(self, c): + """Fill the entire clip rectangle with color ``c``. + + Args: + c: Fill color. + + Returns: + ``Area`` of the filled clip, or ``None`` if empty. + """ return self.fill_rect(self._clip.x, self._clip.y, self._clip.w, self._clip.h, c) def fill_rect(self, x, y, w, h, c): + """Fill the intersection of the given rectangle with the clip. + + Args: + x: Left edge. + y: Top edge. + w: Width. + h: Height. + c: Fill color. + + Returns: + Clipped ``Area`` that was filled, or ``None`` if no overlap. + """ hit = intersect_rect(x, y, w, h, self._clip) if hit is None: return None @@ -73,12 +117,46 @@ def fill_rect(self, x, y, w, h, c): return hit def hline(self, x, y, w, c): + """Draw a horizontal line clipped to this region. + + Args: + x: Start x. + y: Y coordinate. + w: Width in pixels. + c: Color. + + Returns: + Clipped ``Area``, or ``None`` if no overlap. + """ return self.fill_rect(x, y, w, 1, c) def vline(self, x, y, h, c): + """Draw a vertical line clipped to this region. + + Args: + x: X coordinate. + y: Start y. + h: Height in pixels. + c: Color. + + Returns: + Clipped ``Area``, or ``None`` if no overlap. + """ return self.fill_rect(x, y, 1, h, c) def blit_rect(self, buf, x, y, w, h): + """Blit an RGB565 buffer, cropping to the clip region. + + Args: + buf: Source RGB565 bytes for a ``w`` × ``h`` rectangle. + x: Destination x. + y: Destination y. + w: Source width. + h: Source height. + + Returns: + Clipped destination ``Area``, or ``None`` if no overlap. + """ hit = intersect_rect(x, y, w, h, self._clip) if hit is None: return None @@ -90,6 +168,19 @@ def blit_rect(self, buf, x, y, w, h): return hit def blit_transparent(self, buf, x, y, w, h, key): + """Blit an RGB565 buffer with transparency, cropped to the clip. + + Args: + buf: Source RGB565 bytes for a ``w`` × ``h`` rectangle. + x: Destination x. + y: Destination y. + w: Source width. + h: Source height. + key: Transparent color key. + + Returns: + Clipped destination ``Area``, or ``None`` if no overlap. + """ from ._shapes import blit_transparent hit = intersect_rect(x, y, w, h, self._clip) @@ -103,17 +194,29 @@ def blit_transparent(self, buf, x, y, w, h, key): class ClipContext: - """Context manager that pushes a clip rectangle onto a :class:`Draw` stack.""" + """Context manager that pushes a clip rectangle onto a :class:`Draw` stack. + + Entering returns the effective (intersected) clip ``Area``. Nested + ``with draw.clip(...)`` blocks intersect. + """ def __init__(self, draw, area): + """Bind this context to ``draw`` and the clip ``area``. + + Args: + draw (Draw): Draw instance whose clip stack is updated. + area (Area): Clip rectangle to push on enter. + """ self._draw = draw self._area = area def __enter__(self): + """Push the clip and return the effective clip ``Area``.""" self._draw._clip_stack.append(self._area) return self._draw._effective_clip() def __exit__(self, exc_type, exc, tb): + """Pop the clip rectangle from the draw stack.""" self._clip_stack_pop() def _clip_stack_pop(self): diff --git a/lib/pygraphics/_draw.py b/lib/pygraphics/_draw.py index 39989fc..e3afb60 100644 --- a/lib/pygraphics/_draw.py +++ b/lib/pygraphics/_draw.py @@ -33,6 +33,12 @@ class Draw: """ def __init__(self, canvas): + """Attach drawing helpers to ``canvas``. + + Args: + canvas: Object with framebuf-compatible drawing methods (e.g. + :class:`~pygraphics.FrameBuffer` or a display driver). + """ self.canvas = canvas self._clip_stack = [] diff --git a/lib/pygraphics/_files.py b/lib/pygraphics/_files.py index 26b0597..dd93d04 100644 --- a/lib/pygraphics/_files.py +++ b/lib/pygraphics/_files.py @@ -1,3 +1,5 @@ +"""Load and save ``FrameBuffer`` images (PBM, PGM, RGB565 BMP) and export modules.""" + from ._bmp565 import load_bmp565_buffer, read_bmp565_header, write_bmp565_file from ._framebuf_plus import GS2_HMSB, GS4_HMSB, GS8, MONO_HLSB, RGB565, FrameBuffer @@ -112,6 +114,9 @@ def pbm_to_framebuffer(filename): Args: filename (str): Filename of the PBM file + + Returns: + FrameBuffer: Loaded monochrome framebuffer. """ with open(filename, "rb") as f: if f.read(3) != b"P4\n": @@ -132,6 +137,9 @@ def pgm_to_framebuffer(filename): Args: filename (str): Filename of the PGM file + + Returns: + FrameBuffer: Loaded greyscale framebuffer (format from max value). """ with open(filename, "rb") as f: if f.read(3) != b"P5\n": @@ -162,6 +170,9 @@ def bmp_to_framebuffer(filename): Args: filename (str): Path to the BMP file. + + Returns: + FrameBuffer: Loaded RGB565 framebuffer. """ with open(filename, "rb") as f: width, height, data_offset = read_bmp565_header(f) diff --git a/lib/pygraphics/_font.py b/lib/pygraphics/_font.py index f4d4a35..e1b252e 100644 --- a/lib/pygraphics/_font.py +++ b/lib/pygraphics/_font.py @@ -154,6 +154,26 @@ class Font: """ def __init__(self, font_data=None, height=None, cached=True): + """Load a romfont from a path, ``memoryview``, or embedded default. + + Binary font layout: 256 glyphs in ASCII order, one byte per pixel row + per glyph (fonts up to 8 pixels wide). If ``height`` is omitted and + ``font_data`` is a path like ``font_8x14.bin``, height is taken from the + name; for a ``memoryview``, height is ``len(data) // 256``. + + Args: + font_data (str | memoryview): Path to a ``.bin`` font file, or a + ``memoryview`` of glyph bytes. Default uses the embedded font + for ``height`` (or 8×8). + height (int): Glyph height in pixels. Overrides height inferred from + the file name when both are set. + cached (bool): If True (default), read the whole file into memory. + If False, seek the open file for each glyph row. + + Raises: + FileNotFoundError: If ``font_data`` is a path that cannot be opened. + RuntimeError: If the file size does not match the expected font size. + """ # Optionally specify font_data to override the font data to use (default # is _font_8x8.FONT). font_data may be a memoryview or a string path to a # font file. The font format is a binary file with the following @@ -326,6 +346,9 @@ def text_width(self, text, scale=1): Args: text (str): The text to measure. scale (int): The scale factor to measure the text at. Default is 1. + + Returns: + int: Width in pixels. """ return len(text) * self._font_width * scale diff --git a/lib/pygraphics/_framebuf_plus.py b/lib/pygraphics/_framebuf_plus.py index 5d457bb..9ff3762 100644 --- a/lib/pygraphics/_framebuf_plus.py +++ b/lib/pygraphics/_framebuf_plus.py @@ -1,3 +1,13 @@ +"""Extended ``FrameBuffer`` with shape helpers, text, and image I/O. + +Format constants match MicroPython ``framebuf`` where applicable: + +* ``MONO_VLSB``, ``MONO_HLSB``, ``MONO_HMSB`` — 1-bit +* ``GS2_HMSB``, ``GS4_HMSB``, ``GS8`` — greyscale +* ``RGB565`` — 16-bit color +* ``RGB888`` — 24-bit color (pygraphics extension; value ``7``) +""" + from . import _files, _font, _shapes from ._area import Area from ._blit_hooks import clip_blit_bounds @@ -97,6 +107,19 @@ class FrameBuffer(_FrameBuffer): """ def __init__(self, buffer, width, height, format, *args, **kwargs): + """Create a framebuffer wrapping ``buffer``. + + Args: + buffer (bytearray): Pixel storage (writable). + width (int): Width in pixels. + height (int): Height in pixels. + format (int): Pixel format constant (``RGB565``, ``RGB888``, etc.). + *args: Forwarded to the base ``framebuf.FrameBuffer`` (e.g. stride). + **kwargs: Forwarded to the base constructor (e.g. ``stride=``). + + Raises: + ValueError: If ``format`` is not a supported constant. + """ self._rgb888 = format == RGB888 # RGB888 is a pydisplay extension; base framebuf never implements it. # Initialize the C extmod base as RGB565 so MicroPython does not fall back @@ -128,22 +151,27 @@ def __init__(self, buffer, width, height, format, *args, **kwargs): @property def color_depth(self): + """Bits per pixel for the current format (1, 2, 4, 8, 16, or 24).""" return self._color_depth @property def width(self): + """Framebuffer width in pixels.""" return self._width @property def height(self): + """Framebuffer height in pixels.""" return self._height @property def buffer(self): + """Underlying pixel buffer passed to the constructor.""" return self._buffer @property def format(self): + """Pixel format constant (``RGB565``, ``RGB888``, ``MONO_HLSB``, …).""" return self._fb_format def fill_rect(self, x, y, w, h, c): @@ -605,6 +633,9 @@ def from_file(filename): Args: filename (str): Filename to load from + + Returns: + FrameBuffer: Image loaded as a framebuffer. """ return _files.load_image(filename) From 119327e995e71504087710c0bca439d81e08639a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 16:46:18 +0000 Subject: [PATCH 2/2] Skip private path segments in mkdocs gen-files walk. --- scripts/mkdocs_gen_ref_pages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/mkdocs_gen_ref_pages.py b/scripts/mkdocs_gen_ref_pages.py index cd4bbfa..7e70079 100644 --- a/scripts/mkdocs_gen_ref_pages.py +++ b/scripts/mkdocs_gen_ref_pages.py @@ -22,7 +22,7 @@ parts = parts[:-1] doc_path = doc_path.with_name("index.md") full_doc_path = full_doc_path.with_name("index.md") - elif parts[-1] == "__main__": + if not parts or parts[-1] == "__main__" or any(part.startswith("_") for part in parts): continue nav[parts] = doc_path.as_posix()