From abe49ba7d7598d978a0b262217f2e37b411e8acd Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Wed, 18 Mar 2026 12:20:55 -0700 Subject: [PATCH] fix(pygame): add fallback border rendering for fonts without box-drawing chars - Detect if font lacks box-drawing glyphs by testing rendering - Use pygame.graphics to draw border when text glyphs unavailable - Adjust content offset to avoid overlapping border - Ensures border always visible regardless of font support This improves compatibility across platforms and font configurations. --- engine/display/backends/pygame.py | 100 +++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 8 deletions(-) diff --git a/engine/display/backends/pygame.py b/engine/display/backends/pygame.py index e0d2773..df92a16 100644 --- a/engine/display/backends/pygame.py +++ b/engine/display/backends/pygame.py @@ -136,6 +136,21 @@ class PygameDisplay: else: self._font = pygame.font.SysFont("monospace", self.cell_height - 2) + # Check if font supports box-drawing characters; if not, try to find one + self._use_fallback_border = False + if self._font: + try: + # Test rendering some key box-drawing characters + test_chars = ["┌", "─", "┐", "│", "└", "┘"] + for ch in test_chars: + surf = self._font.render(ch, True, (255, 255, 255)) + # If surface is empty (width=0 or all black), font lacks glyph + if surf.get_width() == 0: + raise ValueError("Missing glyph") + except Exception: + # Font doesn't support box-drawing, will use line drawing fallback + self._use_fallback_border = True + self._initialized = True def show(self, buffer: list[str], border: bool = False) -> None: @@ -184,14 +199,26 @@ class PygameDisplay: fps = 1000.0 / avg_ms frame_time = avg_ms - # Apply border if requested - if border: - from engine.display import render_border - - buffer = render_border(buffer, self.width, self.height, fps, frame_time) - self._screen.fill((0, 0, 0)) + # If border requested but font lacks box-drawing glyphs, use graphical fallback + if border and self._use_fallback_border: + self._draw_fallback_border(fps, frame_time) + # Adjust content area to fit inside border + content_offset_x = self.cell_width + content_offset_y = self.cell_height + self.window_width - 2 * self.cell_width + self.window_height - 2 * self.cell_height + else: + # Normal rendering (with or without text border) + content_offset_x = 0 + content_offset_y = 0 + + if border: + from engine.display import render_border + + buffer = render_border(buffer, self.width, self.height, fps, frame_time) + blit_list = [] for row_idx, line in enumerate(buffer[: self.height]): @@ -199,7 +226,7 @@ class PygameDisplay: break tokens = parse_ansi(line) - x_pos = 0 + x_pos = content_offset_x for text, fg, bg, _bold in tokens: if not text: @@ -219,10 +246,17 @@ class PygameDisplay: self._glyph_cache[cache_key] = self._font.render(text, True, fg) surface = self._glyph_cache[cache_key] - blit_list.append((surface, (x_pos, row_idx * self.cell_height))) + blit_list.append( + (surface, (x_pos, content_offset_y + row_idx * self.cell_height)) + ) x_pos += self._font.size(text)[0] self._screen.blits(blit_list) + + # Draw fallback border using graphics if needed + if border and self._use_fallback_border: + self._draw_fallback_border(fps, frame_time) + self._pygame.display.flip() elapsed_ms = (time.perf_counter() - t0) * 1000 @@ -231,6 +265,56 @@ class PygameDisplay: chars_in = sum(len(line) for line in buffer) monitor.record_effect("pygame_display", elapsed_ms, chars_in, chars_in) + def _draw_fallback_border(self, fps: float, frame_time: float) -> None: + """Draw border using pygame graphics primitives instead of text.""" + if not self._screen or not self._pygame: + return + + # Colors + border_color = (0, 255, 0) # Green (like terminal border) + text_color = (255, 255, 255) + + # Calculate dimensions + x1 = 0 + y1 = 0 + x2 = self.window_width - 1 + y2 = self.window_height - 1 + + # Draw outer rectangle + self._pygame.draw.rect( + self._screen, border_color, (x1, y1, x2 - x1 + 1, y2 - y1 + 1), 1 + ) + + # Draw top border with FPS + if fps > 0: + fps_text = f" FPS:{fps:.0f}" + else: + fps_text = "" + # We need to render this text with a fallback font that has basic ASCII + # Use system font which should have these characters + try: + font = self._font # May not have box chars but should have alphanumeric + text_surf = font.render(fps_text, True, text_color, (0, 0, 0)) + text_rect = text_surf.get_rect() + # Position on top border, right-aligned + text_x = x2 - text_rect.width - 5 + text_y = y1 + 2 + self._screen.blit(text_surf, (text_x, text_y)) + except Exception: + pass + + # Draw bottom border with frame time + if frame_time > 0: + ft_text = f" {frame_time:.1f}ms" + try: + ft_surf = self._font.render(ft_text, True, text_color, (0, 0, 0)) + ft_rect = ft_surf.get_rect() + ft_x = x2 - ft_rect.width - 5 + ft_y = y2 - ft_rect.height - 2 + self._screen.blit(ft_surf, (ft_x, ft_y)) + except Exception: + pass + def clear(self) -> None: if self._screen and self._pygame: self._screen.fill((0, 0, 0))