Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/pygraphics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,6 +82,7 @@ def implementation():
"MONO_HMSB",
"MONO_VLSB",
"RGB565",
"RGB888",
"Area",
"ClipContext",
"ClippedCanvas",
Expand Down
64 changes: 63 additions & 1 deletion lib/pygraphics/_bmp565.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
107 changes: 105 additions & 2 deletions lib/pygraphics/_clip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions lib/pygraphics/_draw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand Down
11 changes: 11 additions & 0 deletions lib/pygraphics/_files.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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":
Expand All @@ -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":
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions lib/pygraphics/_font.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading