From 19a69b754ef47a153ec78501f6a0ee65866a322c Mon Sep 17 00:00:00 2001 From: Pete Haughie Date: Thu, 9 Oct 2025 18:56:57 +0200 Subject: [PATCH] Improve font rendering: preserve alpha and make strokes outline-only - Simple API font face loading - System fonts are listed first but custom TTF and OTF will load from the data folder - Render text with RGB then apply per-surface alpha when an RGBA color is provided, ensuring transparent fills/strokes composite correctly. - Build stroke outlines via masks (subtract fill mask) so strokes don't appear inside glyph interiors when fill is transparent. - Mirror behavior for OffscreenSurface, add/adjust type annotations, and fix indentation/flow issues. - Tests & typing: all tests pass (110 passed, 2 skipped); mypy clean. --- examples/font_example.py | 90 +++++++++ src/pycreative/app.py | 322 +++++++++++++++++++++++++++++--- src/pycreative/assets.py | 191 ++++++++++++++++++- src/pycreative/graphics.py | 286 +++++++++++++++++++++++++++- tests/test_font_lifecycle.py | 34 ++++ tests/test_text_font_pending.py | 50 +++++ tests/test_use_font.py | 12 ++ 7 files changed, 953 insertions(+), 32 deletions(-) create mode 100644 examples/font_example.py create mode 100644 tests/test_font_lifecycle.py create mode 100644 tests/test_text_font_pending.py create mode 100644 tests/test_use_font.py diff --git a/examples/font_example.py b/examples/font_example.py new file mode 100644 index 0000000..4abe816 --- /dev/null +++ b/examples/font_example.py @@ -0,0 +1,90 @@ +from pycreative.app import Sketch + + +class FontExample(Sketch): + """A comprehensive font example showing system font discovery, + loading a font (system or bundled), setting active fonts/sizes, and + rendering text under transforms. + """ + + def setup(self): + # Create a window and title + self.size(640, 240) + self.set_title("Font Example — system fonts, load_font, transforms") + + # 1) Inspect available fonts (bundled first, then system names) + fonts = self.list_fonts() + if fonts: + print("Sample fonts:", ", ".join(str(f) for f in fonts[:30])) + + # 2) Prefer a bundled font named 'opensans-regular' if present in sketch data + chosen = None + try: + names_with_paths = self.list_fonts(include_paths=True) + for name, path in names_with_paths: + if name and isinstance(name, str) and name.lower().startswith("opensans") and path: + # load relative to the sketch data folder + # Use only the filename part so Assets can resolve it relative to sketch data + fname = path.split("/")[-1].split("\\")[-1] + bundled = self.load_font(fname, size=14) + if bundled: + self.text_font(bundled) + chosen = bundled + break + except Exception: + pass + + # 3) If no bundled font chosen, ask Assets to load a system font name + if chosen is None: + try: + maybe = self.load_font("courier", size=18) + if maybe: + self.text_font(maybe) + chosen = maybe + except Exception: + pass + + # 4) Demonstrate loading a bundled TTF from sketch data (uncomment if you add a TTF in examples/data) + # bundled = self.load_font('OpenSans-Regular.ttf', size=56) + # if bundled: + # self.text_font(bundled) + + # 5) Set a default size for name-based font creation + self.text_size(60) + + # Set a fill color used by text() when color arg is omitted + self.fill(20, 30, 120) + + def draw(self): + # Clear background and draw labels showing transforms + self.background(0) + + # Draw non-transformed text at top-left + self.use_font("impact", size=48) + self.push() + try: + self.translate(10, 10) + self.fill(255, 0, 0, 75) + self.stroke(0, 255, 0, 100) + self.stroke_width(2) + self.text("Top-left (no transform)", 0, 0) + finally: + self.pop() + + # Draw rotated + scaled text to demonstrate transform propagation + self.use_font("courier", size=32) + self.push() + try: + self.fill(0, 0, 255, 100) + self.stroke(255, 0, 0, 75) + self.translate(100, 70) + self.rotate(0.3) + self.scale(1.25) + # The text origin here is transformed by translate+rotate+scale + self.text("Transformed text", 0, 0) + finally: + self.pop() + + +if __name__ == "__main__": + FontExample().run() diff --git a/src/pycreative/app.py b/src/pycreative/app.py index 5bc4c45..61ad339 100644 --- a/src/pycreative/app.py +++ b/src/pycreative/app.py @@ -82,6 +82,9 @@ def __init__(self, sketch_path: Optional[str] = None, seed: int | None = None) - self._pending_tint: tuple | Any = _PENDING_UNSET # Pending blend mode self._pending_blend: Optional[str] | Any = _PENDING_UNSET + # Pending font and text size state (applied when surface exists) + self._pending_font: object | Any = _PENDING_UNSET + self._pending_text_size: int | Any = _PENDING_UNSET # Pending line cap / join style (butt, round, square) / (miter, round, bevel) self._pending_line_cap: Optional[str] | Any = _PENDING_UNSET self._pending_line_join: Optional[str] | Any = _PENDING_UNSET @@ -869,10 +872,171 @@ def color_mode(self, mode: Optional[str] = None, max1: int = 255, max2: int = 25 pass return None - def text(self, txt: str, x: int, y: int, font_name: Optional[str] = None, size: int = 24, color: Tuple[int, int, int] = (0, 0, 0)) -> None: + def text(self, txt: str, x: int, y: int, font_name: Optional[str] = None, size: int = 24, color: Optional[Tuple[int, int, int]] = None) -> None: if self.surface is not None: self.surface.text(txt, x, y, font_name=font_name, size=size, color=color) + def load_font(self, path: str, size: int = 24): + """Load a font from the sketch's data folder via the Assets manager. + + Returns a pygame.font.Font instance or None. + """ + if self.assets is None: + # Assets exist only when the sketch is running; create an Assets + # instance relative to the sketch file if sketch_path is known. + sketch_dir = os.path.dirname(self.sketch_path) if self.sketch_path else os.getcwd() + try: + self.assets = Assets(sketch_dir) + except Exception: + return None + return self.assets.load_font(path, size=size) + + def create_font(self, path: str, size: int = 24): + """Alias for load_font() for Processing-style API parity.""" + return self.load_font(path, size=size) + + def list_fonts(self, include_paths: bool = False) -> list: + """Return available fonts, preferring bundled fonts in the sketch `data/` + directory followed by system-installed font family names. + + This is a thin convenience wrapper around `Assets.list_fonts()` so + sketches can call `self.list_fonts()` without importing pygame. + """ + if self.assets is None: + sketch_dir = os.path.dirname(self.sketch_path) if self.sketch_path else os.getcwd() + try: + self.assets = Assets(sketch_dir) + except Exception: + return [] + try: + return self.assets.list_fonts(include_paths=include_paths) + except Exception: + return [] + + def text_size(self, size: int | None = None): + """Get or set the default text size for subsequent text() calls. + + When called before a Surface exists the value is stored as pending and + applied when the surface is created. + """ + if size is None: + v = self._pending_text_size + if v is _PENDING_UNSET: + return None + return int(v) + try: + self._pending_text_size = int(size) + except Exception: + pass + return None + + def text_font(self, font: object | None = None, size: int | None = None): + """Set the active font for subsequent text() calls. + + `font` may be a pygame.font.Font instance or a font-name string. + When called before a Surface exists this records the choice as pending. + """ + if self.surface is not None: + # If a size is provided, prefer creating/loading a Font instance. + if isinstance(font, str) and size is not None and self.assets is not None: + loaded = self.assets.load_font(font, size=size) + try: + self.surface._active_font = loaded + except Exception: + pass + return None + try: + self.surface._active_font = font + except Exception: + pass + return None + # record pending choices + self._pending_font = font + if size is not None: + try: + self._pending_text_size = int(size) + except Exception: + pass + return None + + def use_font(self, font: str | object, size: int | None = None): + """Convenience: load (if needed) and set the active font for the sketch. + + `font` may be a pygame.font.Font instance or a family/name string. + If a string is provided this will attempt to load a concrete Font + instance (preferring TTF/OTF) and set it as the active font for + subsequent draw calls. Returns the pygame.font.Font instance when + available or None on failure. + """ + # If caller provided an already-created Font instance, assign it + if not isinstance(font, str): + if self.surface is not None: + try: + self.surface._active_font = font + except Exception: + pass + else: + # keep as pending so initialize/run will apply it + self._pending_font = font + if size is not None: + try: + self._pending_text_size = int(size) + except Exception: + pass + return font + + # If the surface isn't ready yet, try to eagerly resolve the font via + # Assets so use_font() can return a concrete pygame.font.Font when + # possible (this matches common expectations in setup()). If that + # fails, fall back to recording a pending font name/size to be + # resolved later during initialize/run. + if self.surface is None: + try: + # Attempt to load via Assets/load_font which will create an + # Assets instance if needed. This may succeed even before the + # display exists (pygame.font.match_font and Font creation + # usually work without an active display). + f = self.load_font(font, size=size) if size is not None else self.load_font(font) + except Exception: + f = None + if f is not None: + try: + # store both concrete and pending so initialize/run will + # see a concrete Font instance and examples can use it + self._loaded_font = f + self._pending_font = f + return f + except Exception: + pass + # Eager resolution failed: record the requested name for later + try: + self._pending_font = font + if size is not None: + self._pending_text_size = int(size) + except Exception: + pass + return None + + # surface exists: load immediately and set active font + try: + f = self.load_font(font, size=size) if size is not None else self.load_font(font) + except Exception: + f = None + if f is not None: + try: + self.surface._active_font = f + except Exception: + pass + return f + # if loading failed, record pending name/size as a fallback + try: + self._pending_font = font + if size is not None: + self._pending_text_size = int(size) + except Exception: + pass + return None + def point(self, x: float, y: float, color: Optional[Tuple[int, int, int]] = None, z: float | None = None) -> None: """Draw a point on the sketch surface. Delegates to Surface.point. @@ -1899,6 +2063,9 @@ def initialize(self, debug: bool = False) -> None: try: if debug: print("[pycreative.initialize] debug: applying pending state to Surface") + # surface must exist here; assert to help static checkers narrow + assert self.surface is not None + # apply pending color mode first so pending fill/stroke are interpreted correctly if getattr(self, "_pending_color_mode", _PENDING_UNSET) is not _PENDING_UNSET: try: @@ -1943,9 +2110,108 @@ def _to_color_tuple(v: object) -> ColorTupleOrNone: if debug: print(f"[pycreative.initialize] debug: applying pending stroke={col}") self.surface.stroke(col) + + # Apply pending font and text size if present. We attempt to honor + # both a pending text size and a pending font value regardless of + # which one was set last. `self._pending_font` may be a string path + # (loadable via Assets) or an already-created Font instance. + try: + pf = getattr(self, "_pending_font", _PENDING_UNSET) + pts = getattr(self, "_pending_text_size", _PENDING_UNSET) + + # First, if a pending text size is present, store/consider it. + if pts is not _PENDING_UNSET: + try: + # no direct Surface API to set default size, but we + # prefer to honor size when creating/loading a font. + # Clear any existing active font first; we'll set below. + self.surface._active_font = None + except Exception: + pass + + # If a pending font was provided as a path string and we have + # assets, try to load it, preferring to use pending size when + # available. + try: + if pf is not _PENDING_UNSET and isinstance(pf, str) and self.assets is not None: + if pts is not _PENDING_UNSET: + try: + from typing import cast as _cast + + pts_i = int(_cast(int, pts)) + except Exception: + pts_i = 24 + f = self.assets.load_font(pf, size=pts_i) + else: + f = self.assets.load_font(pf) + if f is not None: + self.surface._active_font = f + try: + # expose the concrete pygame.font.Font on the + # Sketch for convenience so examples can + # reference it directly (e.g., self._loaded_font) + self._loaded_font = f + except Exception: + pass + elif pf is not _PENDING_UNSET: + # pf is likely an already-created Font instance; assign it. + try: + self.surface._active_font = pf + try: + self._loaded_font = pf + except Exception: + pass + except Exception: + pass + except Exception: + pass + except Exception: + pass if self._pending_stroke_weight is not _PENDING_UNSET: if isinstance(self._pending_stroke_weight, int): self.surface.stroke_weight(self._pending_stroke_weight) + # Apply pending font and text size as in initialize(); ensure + # fonts/text size requested in setup() before display creation + # are honored when run() creates the display surface. + try: + pf = getattr(self, "_pending_font", _PENDING_UNSET) + pts = getattr(self, "_pending_text_size", _PENDING_UNSET) + if pts is not _PENDING_UNSET: + try: + self.surface._active_font = None + except Exception: + pass + try: + if pf is not _PENDING_UNSET and isinstance(pf, str) and self.assets is not None: + if pts is not _PENDING_UNSET: + try: + from typing import cast as _cast + + pts_i = int(_cast(int, pts)) + except Exception: + pts_i = 24 + f = self.assets.load_font(pf, size=pts_i) + else: + f = self.assets.load_font(pf) + if f is not None: + self.surface._active_font = f + try: + self._loaded_font = f + except Exception: + pass + elif pf is not _PENDING_UNSET: + try: + self.surface._active_font = pf + try: + self._loaded_font = pf + except Exception: + pass + except Exception: + pass + except Exception: + pass + except Exception: + pass if getattr(self, "_pending_line_cap", _PENDING_UNSET) is not _PENDING_UNSET: try: @@ -2161,21 +2427,35 @@ def run(self, max_frames: Optional[int] = None, debug: bool = False) -> None: pass pygame.display.set_caption(self._title) # Same cast as earlier: ensure the typechecker knows this is a pygame.Surface. - self.surface = GraphicsSurface(cast(pygame.Surface, self._surface)) + # If initialize() already created a GraphicsSurface wrapper and it + # references the same underlying pygame.Surface, reuse that wrapper + # so we don't lose surface-local state (for example, _active_font). + try: + existing = getattr(self, "surface", None) + if existing is not None and getattr(existing, "_surf", None) is self._surface: + # reuse the existing GraphicsSurface instance + pass + else: + self.surface = GraphicsSurface(cast(pygame.Surface, self._surface)) + except Exception: + # On any failure, fall back to creating a fresh wrapper. + self.surface = GraphicsSurface(cast(pygame.Surface, self._surface)) # Apply any drawing state set earlier in setup() before the Surface existed try: if debug: print("[pycreative.run] debug: applying pending state to Surface") # apply pending color mode first so pending fill/stroke are interpreted correctly + assert self.surface is not None + surf = self.surface if getattr(self, "_pending_color_mode", _PENDING_UNSET) is not _PENDING_UNSET: try: cm = self._pending_color_mode # cm may be (mode, max1, max2, max3) or include a 5th alpha max if isinstance(cm, tuple) and len(cm) >= 4: if len(cm) >= 5: - self.surface.color_mode(cm[0], cm[1], cm[2], cm[3], cm[4]) + surf.color_mode(cm[0], cm[1], cm[2], cm[3], cm[4]) else: - self.surface.color_mode(cm[0], cm[1], cm[2], cm[3]) + surf.color_mode(cm[0], cm[1], cm[2], cm[3]) except Exception: pass @@ -2194,7 +2474,7 @@ def _to_color_tuple(v: object) -> ColorTupleOrNone: val = _to_color_tuple(self._pending_fill) if debug: print(f"[pycreative.run] debug: applying pending fill={val}") - self.surface.fill(val) + surf.fill(val) if self._pending_stroke is not _PENDING_UNSET: def _to_color_tuple(v: object) -> ColorTupleOrNone: if v is None: @@ -2209,27 +2489,27 @@ def _to_color_tuple(v: object) -> ColorTupleOrNone: col = _to_color_tuple(self._pending_stroke) if debug: print(f"[pycreative.run] debug: applying pending stroke={col}") - self.surface.stroke(col) + surf.stroke(col) if self._pending_stroke_weight is not _PENDING_UNSET: if isinstance(self._pending_stroke_weight, int): - self.surface.stroke_weight(self._pending_stroke_weight) + surf.stroke_weight(self._pending_stroke_weight) # apply pending line cap/join if present if getattr(self, "_pending_line_cap", _PENDING_UNSET) is not _PENDING_UNSET: try: v = self._pending_line_cap if v is None: - self.surface.set_line_cap("butt") + surf.set_line_cap("butt") else: - self.surface.set_line_cap(v) + surf.set_line_cap(v) except Exception: pass if getattr(self, "_pending_line_join", _PENDING_UNSET) is not _PENDING_UNSET: try: v = self._pending_line_join if v is None: - self.surface.set_line_join("miter") + surf.set_line_join("miter") else: - self.surface.set_line_join(v) + surf.set_line_join(v) except Exception: pass # apply pending background if present @@ -2253,7 +2533,7 @@ def _to_color_tuple_local(v: object) -> ColorTupleOrNone: coerced_bg = _to_color_tuple_local(bg) if coerced_bg is not None: try: - self.surface.clear(coerced_bg) + surf.clear(coerced_bg) except Exception: pass else: @@ -2262,33 +2542,33 @@ def _to_color_tuple_local(v: object) -> ColorTupleOrNone: if isinstance(bg, _Color): try: - self.surface.clear(bg) + surf.clear(bg) except Exception: pass elif isinstance(bg, (int, float)): try: - self.surface.clear(int(bg) & 255) + surf.clear(int(bg) & 255) except Exception: pass elif isinstance(bg, tuple): try: if len(bg) in (3, 4): t3 = tuple(int(x) for x in bg) - self.surface.clear(cast(ColorInput, t3)) + surf.clear(cast(ColorInput, t3)) except Exception: pass except Exception: # Last-resort guarded handling without importing Color if isinstance(bg, (int, float)): try: - self.surface.clear(int(bg) & 255) + surf.clear(int(bg) & 255) except Exception: pass elif isinstance(bg, tuple): try: if len(bg) in (3, 4): t3 = tuple(int(x) for x in bg) - self.surface.clear(cast(ColorInput, t3)) + surf.clear(cast(ColorInput, t3)) except Exception: pass self._pending_background = _PENDING_UNSET @@ -2299,14 +2579,14 @@ def _to_color_tuple_local(v: object) -> ColorTupleOrNone: try: rm = self._pending_rect_mode if isinstance(rm, str) or rm is None: - self.surface.rect_mode(rm) + surf.rect_mode(rm) except Exception: pass if getattr(self, "_pending_ellipse_mode", _PENDING_UNSET) is not _PENDING_UNSET: try: em = self._pending_ellipse_mode if isinstance(em, str) or em is None: - self.surface.ellipse_mode(em) + surf.ellipse_mode(em) except Exception: pass except Exception: @@ -2426,8 +2706,8 @@ def _to_color_tuple_local(v: object) -> ColorTupleOrNone: pygame.display.flip() if debug: try: - surf = pygame.display.get_surface() - print(f"[pycreative.run] debug: pygame.display.get_surface() exists={surf is not None}") + ds = pygame.display.get_surface() + print(f"[pycreative.run] debug: pygame.display.get_surface() exists={ds is not None}") except Exception: pass self.frame_count += 1 diff --git a/src/pycreative/assets.py b/src/pycreative/assets.py index 9c445ad..5ef01bd 100644 --- a/src/pycreative/assets.py +++ b/src/pycreative/assets.py @@ -9,13 +9,16 @@ class Assets: + # Cache mapping (path or (path,size)) -> loaded asset + cache: Dict[Any, Any] def __init__(self, sketch_dir: str, debug: bool = False): self.sketch_dir = sketch_dir # enable verbose debug printing when True self.debug = bool(debug) - # cache maps resolved absolute path -> asset (pygame.Surface or PShape or filepath) - # Store as an instance attribute so static analyzers understand `self.cache` - self.cache: Dict[str, Any] = {} + # cache maps resolved absolute path or (path,size) tuples -> asset + # (pygame.Surface, pygame.font.Font, PShape, or filepath). Store as an + # instance attribute so static analyzers understand `self.cache`. + self.cache = {} def _resolve_path(self, path: str) -> Optional[str]: parts = path.replace("\\", "/").split("/") @@ -104,3 +107,185 @@ def load_media(self, path: str) -> Optional[str]: return None return resolved + def load_font(self, path: str, size: int = 24): + """Resolve a font file path in the sketch and return a pygame.font.Font instance or None. + + `path` may be relative to the sketch's `data/` folder or a direct file path. + """ + # Try to find a system-installed font first (by family/name). This + # lets users pass a font name like "arial" and get a system-provided + # TTF if installed. Use pygame.font.match_font which returns an + # absolute path to a matching font file or None. + try: + try: + sys_path = pygame.font.match_font(path) + except Exception: + sys_path = None + # Ignore font collection files (.ttc) because FreeType/SDL may + # select an unpredictable face from the collection which can + # look unlike the requested family. Treat .ttc as no-match so + # callers can fall back to local assets or explicit TTF names. + try: + if isinstance(sys_path, str) and sys_path.lower().endswith('.ttc'): + if self.debug: + print(f"[Assets] Debug: Ignoring matched TTC font path: {sys_path}") + sys_path = None + except Exception: + pass + # If match_font returned nothing or a TTC was ignored, attempt a + # smarter search through available system font family names to + # prefer a non-.ttc (TTF/OTF) match. This helps on macOS where + # 'courier' may resolve to a TTC but 'courier new' resolves to a + # TTF. + if not sys_path: + try: + sys_fonts = pygame.font.get_fonts() or [] + except Exception: + sys_fonts = [] + # Normalized requested name for loose matching + try: + req_norm = str(path).lower().replace(' ', '') + except Exception: + req_norm = '' + # Prepare ordered candidates that are likely to succeed + candidates = [] + # common explicit variant + if req_norm: + candidates.append(req_norm + 'new') + candidates.append(''.join(req_norm.split())) + # then any family that contains the requested substring + for fam in sys_fonts: + try: + fam_norm = fam.lower().replace(' ', '') + except Exception: + fam_norm = fam + if req_norm and (req_norm in fam_norm or fam_norm in req_norm): + candidates.append(fam) + # Finally try all system fonts (best-effort) + candidates.extend(sys_fonts) + # Try each candidate via match_font and pick the first non-TTC + chosen = None + for cand in candidates: + try: + cand_path = pygame.font.match_font(cand) + except Exception: + cand_path = None + if not cand_path: + continue + try: + if cand_path.lower().endswith('.ttc'): + if self.debug: + print(f"[Assets] Debug: Skipping TTC candidate {cand} -> {cand_path}") + continue + except Exception: + pass + # Found a usable non-TTC system font file + chosen = cand_path + if self.debug: + print(f"[Assets] Debug: Using system font candidate {cand} -> {chosen}") + break + if chosen: + sys_path = chosen + if sys_path: + key = (sys_path, int(size)) + if key in self.cache: + return self.cache[key] + try: + font = pygame.font.Font(sys_path, int(size)) + self.cache[key] = font + return font + except Exception as e: + if self.debug: + print(f"[Assets] Debug: match_font returned {sys_path} but loading failed: {e}") + # fall through to try resolving local path + except Exception: + # If pygame isn't available or something failed, proceed to local lookup + pass + + # Fallback: attempt to resolve as a local file relative to the sketch + resolved = self._resolve_path(path) + if not resolved: + if self.debug: + print(f"[Assets] Debug: font '{path}' not found locally in sketch data or examples") + print(f"[Assets] Error: font '{path}' not found in 'data/' or sketch directory: {self.sketch_dir}") + return None + key = (resolved, int(size)) + if key in self.cache: + return self.cache[key] + try: + font = pygame.font.Font(resolved, int(size)) + # cache by resolved path + size + self.cache[key] = font + return font + except Exception as e: + print(f"[Assets] Error loading font '{resolved}': {e}") + return None + + def list_fonts(self, include_paths: bool = False) -> list: + """Return a list of available fonts. + + The returned list places fonts found under the sketch's data/examples + directories first (local bundled fonts), followed by system-installed + font family names discovered via pygame.font.get_fonts(). + + When `include_paths` is True the function returns a list of tuples + (name, path) for local fonts and (name, None) for system families. + """ + local_fonts: dict[str, str] = {} + # Candidate directories to search for bundled fonts + candidates = [ + os.path.join(self.sketch_dir, "data"), + os.path.join(self.sketch_dir), + os.path.join(self.sketch_dir, "examples", "data"), + os.path.join(self.sketch_dir, "examples"), + ] + # Prefer individual font files; ignore font collections (.ttc) + exts = {".ttf", ".otf"} + for base in candidates: + try: + if not os.path.isdir(base): + continue + for entry in os.listdir(base): + try: + lower = entry.lower() + name, ext = os.path.splitext(lower) + if ext in exts: + full = os.path.join(base, entry) + # Use display name without extension; prefer first-found + if name not in local_fonts: + local_fonts[name] = full + except Exception: + continue + except Exception: + continue + + results: list = [] + # Add local fonts first + for name, path in local_fonts.items(): + if include_paths: + results.append((name, path)) + else: + results.append(name) + + # Then append system font family names + try: + import pygame as _pygame + + try: + sys_fonts = _pygame.font.get_fonts() or [] + except Exception: + sys_fonts = [] + except Exception: + sys_fonts = [] + + # Append system names avoiding duplicates + for fam in sys_fonts: + if fam in local_fonts: + continue + if include_paths: + results.append((fam, None)) + else: + results.append(fam) + + return results + diff --git a/src/pycreative/graphics.py b/src/pycreative/graphics.py index 3435240..2bfc3f1 100644 --- a/src/pycreative/graphics.py +++ b/src/pycreative/graphics.py @@ -100,6 +100,11 @@ def __init__(self, surf: pygame.Surface) -> None: # allocating many small surfaces each frame. This is a small, short- # lived cache and not intended for long-term memory growth. self._temp_surface_cache: dict[tuple[int, int], pygame.Surface] = {} + # Active font stored on the Surface; may be a pygame.font.Font or None + # Stored on the instance to make assignments type-checkable from + # external modules (e.g., Sketch.apply pending state). Use a generic + # object annotation at runtime to avoid import-time pygame requirements. + self._active_font: object | None = None def _get_temp_surface(self, w: int, h: int) -> pygame.Surface: """Return a cached SRCALPHA temporary surface for the given size. @@ -424,15 +429,226 @@ def size(self) -> tuple[int, int]: """Convenience property returning (width, height) of the surface.""" return self.get_size() - def text(self, txt: str, x: int, y: int, font_name: Optional[str] = None, size: int = 24, color: Tuple[int, int, int] = (0, 0, 0)) -> None: + def text(self, txt: str, x: int, y: int, font_name: Optional[object] = None, size: int = 24, color: Optional[Tuple[int, int, int]] = None) -> None: """Render text onto the surface. Provided on Surface for convenience so sketches can call `self.surface.text(...)` regardless of whether the surface is on- or off-screen. """ try: - font = pygame.font.SysFont(font_name, int(size)) - surf = font.render(str(txt), True, color) - self._surf.blit(surf, (int(x), int(y))) + # Accept either a pygame.font.Font object or a font name. + font_obj = None + # If user provided an explicit Font instance, use it. + try: + import pygame as _pygame + + if font_name is not None and isinstance(font_name, _pygame.font.Font): + font_obj = font_name + except Exception: + pass + + # Prefer an explicitly set active font on the Surface if present + if font_obj is None and getattr(self, "_active_font", None) is not None: + font_obj = self._active_font # type: ignore[assignment] + + if font_obj is None: + font_obj = pygame.font.SysFont(font_name if isinstance(font_name, str) else None, int(size)) + + # Determine color: prefer explicit argument, otherwise use surface fill + col_arg: ColorTuple = (0, 0, 0) + try: + if color is not None: + coerced = self._coerce_input_color(color) + else: + coerced = self._fill + # _coerce_input_color may return a 3- or 4-tuple; preserve values when possible + if coerced is None: + col_arg = (0, 0, 0) + else: + try: + if len(coerced) >= 4: + col_arg = (int(coerced[0]), int(coerced[1]), int(coerced[2]), int(coerced[3])) + else: + col_arg = (int(coerced[0]), int(coerced[1]), int(coerced[2])) + except Exception: + col_arg = (0, 0, 0) + except Exception: + col_arg = (0, 0, 0) + + # Help static type checkers: treat font_obj as a pygame Font here + # single declaration to satisfy static checkers + from typing import Any as _Any + + font_real: _Any = None + try: + font_real = cast(pygame.font.Font, font_obj) + except Exception: + font_real = font_obj + + # font.render expects an RGB tuple; if we were given an RGBA + # preserve the alpha by rendering RGB then applying per-surface + # alpha to the resulting Surface. + try: + if isinstance(col_arg, tuple) and len(col_arg) >= 4: + render_color = (int(col_arg[0]), int(col_arg[1]), int(col_arg[2])) + render_alpha = int(col_arg[3]) + else: + render_color = (int(col_arg[0]), int(col_arg[1]), int(col_arg[2])) + render_alpha = None + except Exception: + render_color = (0, 0, 0) + render_alpha = None + + surf = font_real.render(str(txt), True, render_color) + try: + if render_alpha is not None: + surf = surf.convert_alpha() + surf.set_alpha(render_alpha) + except Exception: + # best-effort: ignore set_alpha failures + pass + + # Prepare stroke if present + stroke_arg: ColorTuple = (0, 0, 0) + stroke_present = False + try: + if getattr(self, "_stroke", None) is not None: + coerced_stroke = self._coerce_input_color(self._stroke) + if coerced_stroke is not None: + if len(coerced_stroke) >= 4: + stroke_arg = (int(coerced_stroke[0]), int(coerced_stroke[1]), int(coerced_stroke[2]), int(coerced_stroke[3])) + else: + stroke_arg = (int(coerced_stroke[0]), int(coerced_stroke[1]), int(coerced_stroke[2])) + stroke_present = True + stroke_w = int(self._stroke_weight) if getattr(self, "_stroke_weight", 0) else 0 + except Exception: + stroke_present = False + stroke_w = 0 + + # If stroke is requested, build a composite surface containing + # stroked outlines (by blitting the stroke color multiple times) + # and then the fill on top. This composite is then blitted or + # transformed similarly to the non-stroked path. + # Ensure pad has a default so later code can reference it safely. + pad = 0 + if stroke_present and stroke_w > 0: + try: + # Create stroke surface (same glyph rendered in stroke color) + try: + if isinstance(stroke_arg, tuple) and len(stroke_arg) >= 4: + s_render_color = (int(stroke_arg[0]), int(stroke_arg[1]), int(stroke_arg[2])) + s_render_alpha = int(stroke_arg[3]) + else: + s_render_color = (int(stroke_arg[0]), int(stroke_arg[1]), int(stroke_arg[2])) + s_render_alpha = None + except Exception: + s_render_color = (0, 0, 0) + s_render_alpha = None + + stroke_surf = font_real.render(str(txt), True, s_render_color) + try: + if s_render_alpha is not None: + stroke_surf = stroke_surf.convert_alpha() + stroke_surf.set_alpha(s_render_alpha) + except Exception: + pass + sw = stroke_surf.get_width() + sh = stroke_surf.get_height() + pad = stroke_w + comp_w = sw + pad * 2 + comp_h = sh + pad * 2 + # Build an expanded stroke mask by blitting the stroke glyph + # around the offsets; then subtract the original fill mask so + # the interior of the glyph doesn't contain stroke pixels. + comp = pygame.Surface((comp_w, comp_h), flags=pygame.SRCALPHA) + comp.fill((0, 0, 0, 0)) + + offsets = [(dx, dy) for dx in range(-pad, pad + 1) for dy in range(-pad, pad + 1) if not (dx == 0 and dy == 0)] + for dx, dy in offsets: + try: + comp.blit(stroke_surf, (pad + dx, pad + dy)) + except Exception: + pass + + try: + # Create masks from the expanded stroke and the fill glyph + stroke_mask = pygame.mask.from_surface(comp) + fill_mask = pygame.mask.from_surface(surf) + # Erase the fill area from the stroke mask so the + # resulting mask represents only the outline region. + stroke_mask.erase(fill_mask, (pad, pad)) + # Convert mask back to a surface preserving alpha + if isinstance(stroke_arg, tuple) and len(stroke_arg) >= 4: + sc = (int(stroke_arg[0]), int(stroke_arg[1]), int(stroke_arg[2]), int(stroke_arg[3])) + else: + sc = (int(stroke_arg[0]), int(stroke_arg[1]), int(stroke_arg[2]), 255) + outline = stroke_mask.to_surface(setcolor=sc, unsetcolor=(0, 0, 0, 0)) + outline = outline.convert_alpha() + out_surf = pygame.Surface((comp_w, comp_h), flags=pygame.SRCALPHA) + out_surf.fill((0, 0, 0, 0)) + out_surf.blit(outline, (0, 0)) + # Finally blit the fill glyph centered at pad so final + # composite contains outline then fill on top. + out_surf.blit(surf, (pad, pad)) + except Exception: + # Fallback to earlier behavior if masks aren't available + try: + comp.blit(surf, (pad, pad)) + except Exception: + pass + out_surf = comp + except Exception: + out_surf = surf + else: + out_surf = surf + + # If no transform is active, blit directly. + if self._is_identity_transform(): + try: + # If we created a composite with padding, align so that the + # logical origin (x,y) matches the top-left of the fill region. + if out_surf is not surf: + # comp had pad pixels inset; offset by -pad + self._surf.blit(out_surf, (int(x) - pad, int(y) - pad)) + else: + self._surf.blit(out_surf, (int(x), int(y))) + except Exception: + pass + else: + try: + # Transform the requested origin + tx, ty = self._transform_point(x, y) + # Estimate uniform scale and rotation angle from matrix + sx, sy = decompose_scale(self._current_matrix()) + avg_scale = (sx + sy) / 2.0 if sx > 0 and sy > 0 else 1.0 + import math + + a = self._current_matrix()[0][0] + b = self._current_matrix()[1][0] + angle = math.degrees(math.atan2(b, a)) + + # Apply rotation+scale via rotozoom; rotozoom rotates around the + # surface center so we will blit the transformed surf centered at + # the transformed origin (consistent with image behaviour). + try: + transformed = pygame.transform.rotozoom(out_surf, -angle, avg_scale) + except Exception: + try: + transformed = pygame.transform.smoothscale(out_surf, (int(out_surf.get_width() * avg_scale), int(out_surf.get_height() * avg_scale))) + except Exception: + transformed = out_surf + + # Blit the transformed text at the transformed origin + # (treat tx,ty as the top-left of the text in transformed space). + # If we used a composite with padding, adjust the blit origin + if out_surf is not surf: + self._surf.blit(transformed, (int(tx) - int(pad * avg_scale), int(ty) - int(pad * avg_scale))) + else: + self._surf.blit(transformed, (int(tx), int(ty))) + except Exception: + try: + self._surf.blit(surf, (int(x), int(y))) + except Exception: + pass except Exception: # best-effort; don't crash sketches if font rendering isn't available return @@ -1030,7 +1246,7 @@ def curve(self, x0: float, y0: float, x1: float, y1: float, x2: float, y2: float for i in range(steps + 1): t = i / steps p = self.curve_point((x0, y0), (x1, y1), (x2, y2), (x3, y3), t) - pts.append(p) + pts.append(cast(tuple[float, float], p)) # draw as open polyline self.polyline(pts) @@ -1574,9 +1790,63 @@ def save(self, path: str) -> None: # --- text/image helpers --- - def text(self, txt: str, x: int, y: int, font_name: Optional[str] = None, size: int = 24, color: Tuple[int, int, int] = (0, 0, 0)) -> None: - font = pygame.font.SysFont(font_name, int(size)) - surf = font.render(str(txt), True, color) + def text(self, txt: str, x: int, y: int, font_name: Optional[object] = None, size: int = 24, color: Optional[Tuple[int, int, int]] = None) -> None: + # Accept either an object Font instance or a font name; mirror Surface.text behavior + try: + font_obj = None + if font_name is not None: + try: + import pygame as _pygame + if isinstance(font_name, _pygame.font.Font): + font_obj = font_name + except Exception: + pass + if font_obj is None: + font_obj = pygame.font.SysFont(font_name if isinstance(font_name, str) else None, int(size)) + font = font_obj + except Exception: + font = pygame.font.SysFont(None, int(size)) + + # Determine color: prefer explicit argument, otherwise use surface fill + col_arg: ColorTuple = (0, 0, 0) + try: + if color is not None: + coerced = self._coerce_input_color(color) + else: + coerced = self._fill + if coerced is None: + col_arg = (0, 0, 0) + else: + try: + if len(coerced) >= 4: + col_arg = (int(coerced[0]), int(coerced[1]), int(coerced[2]), int(coerced[3])) + else: + col_arg = (int(coerced[0]), int(coerced[1]), int(coerced[2])) + except Exception: + col_arg = (0, 0, 0) + except Exception: + col_arg = (0, 0, 0) + + # Render with RGB and apply alpha if provided + try: + if isinstance(col_arg, tuple) and len(col_arg) >= 4: + render_color = (int(col_arg[0]), int(col_arg[1]), int(col_arg[2])) + render_alpha = int(col_arg[3]) + else: + render_color = (int(col_arg[0]), int(col_arg[1]), int(col_arg[2])) + render_alpha = None + except Exception: + render_color = (0, 0, 0) + render_alpha = None + + surf = font.render(str(txt), True, render_color) + try: + if render_alpha is not None: + surf = surf.convert_alpha() + surf.set_alpha(render_alpha) + except Exception: + pass + self._surf.blit(surf, (int(x), int(y))) def load_image(self, path: str) -> pygame.Surface: diff --git a/tests/test_font_lifecycle.py b/tests/test_font_lifecycle.py new file mode 100644 index 0000000..e2e9a27 --- /dev/null +++ b/tests/test_font_lifecycle.py @@ -0,0 +1,34 @@ +import os +import pygame + +from pycreative.app import Sketch + + +def test_pending_font_applied_after_run(): + """If a sketch requests a font before display creation (use_font), + after run()/initialize the surface should have the concrete font when + one was resolvable. + """ + # Use dummy video driver for headless CI environments + os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + # initialize pygame explicitly to ensure font subsystem is available + pygame.init() + + s = Sketch() + # Request a common system family before the display exists + s.use_font("courier new", size=24) + + # Run one frame to allow pending state to be applied during initialize/run + s.run(max_frames=1) + + try: + # If the sketch resolved a concrete pygame.font.Font earlier, it + # should be exposed as _loaded_font and the surface should have the + # same active font instance. + lf = getattr(s, "_loaded_font", None) + if lf is not None: + assert s.surface is not None + assert getattr(s.surface, "_active_font", None) is not None + assert s.surface._active_font is lf + finally: + pygame.quit() diff --git a/tests/test_text_font_pending.py b/tests/test_text_font_pending.py new file mode 100644 index 0000000..2509cc8 --- /dev/null +++ b/tests/test_text_font_pending.py @@ -0,0 +1,50 @@ +import os +import sys +import pathlib + +# Make local `src/` visible on sys.path so tests import the in-repo package +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src")) + + +def _init_pygame_dummy(): + os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + import pygame + + pygame.init() + return pygame + + +def test_text_font_and_size_pending_applied(): + pygame = _init_pygame_dummy() + + from pycreative.app import Sketch + + class _S(Sketch): + def setup(self): + # Set a font instance and size before the display exists + f = pygame.font.SysFont(None, 32) + self.text_size(32) + self.text_font(f) + + def draw(self): + # draw nothing + pass + + s = _S() + # Call initialize() to exercise the pending-state application path + s.initialize() + + assert s.surface is not None + # Surface should have an active font instance assigned + assert getattr(s.surface, "_active_font", None) is not None + # The active font should be a pygame.font.Font instance + + try: + import pygame as _pygame + + assert isinstance(s.surface._active_font, _pygame.font.Font) + except Exception: + # If pygame.font isn't available for some reason, at least ensure a non-None value was stored + assert s.surface._active_font is not None + + pygame.quit() diff --git a/tests/test_use_font.py b/tests/test_use_font.py new file mode 100644 index 0000000..d883bda --- /dev/null +++ b/tests/test_use_font.py @@ -0,0 +1,12 @@ +import os +import pygame +from pycreative.app import Sketch + + +def test_use_font_returns_font_instance(): + os.environ.setdefault('SDL_VIDEODRIVER', 'dummy') + pygame.init() + s = Sketch() + f = s.use_font('courier new', size=24) + assert f is None or hasattr(f, 'render') # Accept either None or a Font with render + pygame.quit()